/* 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 (
Client portal admin
{ setDraft(e.target.value); setErr(''); }} onKeyDown={e=>{ if (e.key==='Enter') commit(); if (e.key==='Escape') setEditing(false); }} className="w-full h-8 px-2 rounded-lg border border-ink-200 dark:border-white/10 bg-transparent text-[11px] font-mono outline-none focus:border-accent-500"/> {err &&
{err}
}
Saved on this device only. Leave empty to remove the shortcut.
); if (!url) return ( ); return (
Client portal admin
); } /* ---------- Admin avatar menu (top-right) -------------------------- */ function AdminUserMenu({ user, onSignOut }){ return ( {user.name} {ROLES[user.role].label} }> {(close)=>(
{user.name}
{user.email}
)} ); } /* ---------- Overview section --------------------------------------- */ function AdminOverview({ users, integrations, audit, onGo }){ const active = users.filter(u=>u.status==='active').length; const byRole = ROLE_ORDER.map(r=>({ role:r, n:users.filter(u=>u.role===r).length })).filter(x=>x.n>0); const connected = integrations.filter(i=>i.status==='connected'); const degraded = integrations.filter(i=>i.health==='degraded' && i.status==='connected'); const offline = integrations.filter(i=>i.status!=='connected'); const feeds = connectedPlatforms(integrations); const platCount = Object.keys(feeds).length; const Stat = ({ label, value, sub, tone='#8351ff', icon }) => (
{label}
{value}
{sub}
); return (
Connection health
onGo('integrations')}>Manage
{integrations.map(i=>{ const tone = i.status!=='connected' ? '#8b8b80' : i.health==='degraded' ? '#f59e0b' : '#10b981'; const lbl = i.status!=='connected' ? 'Disconnected' : i.health==='degraded' ? 'Degraded' : 'Healthy'; return (
{i.name} {i.kind}
{i.endpoint || 'not configured'}
{lbl}
); })}
Team by role
onGo('users')}>Open team
{byRole.map(({role,n})=>{ const r = ROLES[role]; const pct = Math.round(n/users.length*100); return (
{r.label} {n}
); })}
{audit && (
Recent activity
onGo('activity')}>Full log
{audit.slice(0,6).map(ev=> )}
)}
); } /* ---------- Account access editor ---------------------------------- */ function AccountAccess({ value, onChange }){ const all = value === '*' || (Array.isArray(value) && value.includes('*')); const ids = all ? [] : (Array.isArray(value) ? value : []); const toggle = (id) => { const next = ids.includes(id) ? ids.filter(x=>x!==id) : [...ids, id]; onChange(next); }; return (
{ACCOUNTS.map(a=>{ const on = all || ids.includes(a.id); return ( ); })}
); } /* ---------- User edit drawer --------------------------------------- */ function UserEditDrawer({ open, user, isNew, onClose, onSave, onDelete, currentUser, users }){ const blank = { name:'', email:'', role:'viewer', accounts:[], password:'', tone:AVATAR_TONES[0], status:'active', twoFA:false, title:'' }; const [draft, setDraft] = useState(blank); const [showPw, setShowPw] = useState(false); const [err, setErr] = useState(''); useEffect(()=>{ if(open){ setDraft(user ? {...user} : blank); setErr(''); setShowPw(false); } }, [open, user]); const set = (k,v)=>setDraft(d=>({...d,[k]:v})); const isSelf = currentUser && user && currentUser.id === user.id; const save = () => { if (!draft.name.trim()) return setErr('Name is required.'); if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(draft.email)) return setErr('Enter a valid email address.'); const dup = users.find(u=>u.email.toLowerCase()===draft.email.trim().toLowerCase() && u.id!==draft.id); if (dup) return setErr('That email is already in use.'); if (isNew && (!draft.password || draft.password.length<6)) return setErr('Set a password (min 6 characters).'); onSave({ ...draft, id: draft.id || ('u_'+Math.random().toString(36).slice(2,9)), email: draft.email.trim(), name: draft.name.trim(), accounts: draft.accounts === '*' ? '*' : draft.accounts, lastActive: draft.lastActive || Date.now(), }); onClose(); }; return ( {isNew?'Invite team member':'Edit member'}} footer={ <> {!isNew && !isSelf && ( )} Cancel {isNew?'Send invite':'Save changes'} }>
{err && (
{err}
)} {/* identity */}
Avatar color
{AVATAR_TONES.map(t=>(
set('name',e.target.value)} placeholder="Jane Doe" className={inputCls}/>
set('email',e.target.value)} placeholder="jane@company.com" className={inputCls}/> set('title',e.target.value)} placeholder="Media Buyer" className={inputCls}/>
{/* role */}
{ROLE_ORDER.map(r=>{ const role = ROLES[r]; const on = draft.role===r; return ( ); })}
{/* accounts */} set('accounts',v)}/> {/* password */}
set('password',e.target.value)} placeholder={isNew?'••••••••':'Unchanged'} className={inputCls+' pr-11'}/>
{/* toggles */}
Two-factor authentication
{draft.twoFA?'Marked for 2FA — sign-in enforcement ships in a later release':'Disabled'}
set('twoFA',v)}/>
Account status
{draft.status==='active'?'Active — can sign in':'Suspended — sign-in blocked'}
set('status', v?'active':'suspended')}/>
); } const inputCls = "w-full h-10 px-3 rounded-xl bg-white dark:bg-ink-900/40 border border-ink-200 dark:border-white/10 text-[13.5px] focus:outline-none focus:border-accent-400 focus:ring-2 focus:ring-accent-100 dark:focus:ring-accent-500/20 transition"; function Field({ label, hint, children }){ return ( ); } /* ---------- Users manager ------------------------------------------ */ function UsersManager({ users, setUsers, currentUser, readOnly, pushAudit }){ const [query, setQuery] = useState(''); const [roleFilter, setRoleFilter] = useState('all'); const [edit, setEdit] = useState(null); // {user, isNew} | null const filtered = users.filter(u=>{ if (roleFilter!=='all' && u.role!==roleFilter) return false; const q = query.trim().toLowerCase(); if (!q) return true; return u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q); }); const [saveErr, setSaveErr] = useState(''); const saveUser = async (u) => { const exists = users.some(x=>x.id===u.id); const payload = { name:u.name, email:u.email, role:u.role, accounts:u.accounts, tone:u.tone, status:u.status, twoFA:u.twoFA, title:u.title }; if (u.password) payload.password = u.password; try { const saved = exists ? await RelayAPI.users.update(u.id, payload) : await RelayAPI.users.create(payload); setUsers(arr => { const i = arr.findIndex(x=>x.id===saved.id); if (i>=0){ const next=[...arr]; next[i]=saved; return next; } return [...arr, saved]; }); setSaveErr(''); if (pushAudit) pushAudit(); } catch(e){ setSaveErr(e.message || 'Could not save member.'); } }; const deleteUser = async (id) => { try { await RelayAPI.users.remove(id); setUsers(arr => arr.filter(x=>x.id!==id)); if(pushAudit) pushAudit(); } catch(e){ setSaveErr(e.message || 'Could not remove member.'); } }; const fmtAgo = (ts)=>{ if(!ts) return '—'; const s=Math.floor((Date.now()-ts)/1000); if(s<60)return s+'s ago'; if(s<3600)return Math.floor(s/60)+'m ago'; if(s<86400)return Math.floor(s/3600)+'h ago'; return Math.floor(s/86400)+'d ago'; }; return (

Team & permissions

{users.length} members · manage roles, access and security.

{!readOnly && ( setEdit({ user:null, isNew:true })}>Invite member )}
setQuery(e.target.value)} placeholder="Search name or email…" className="w-full h-full pl-8 pr-3 rounded-lg border border-ink-200 dark:border-white/10 bg-white dark:bg-ink-900/40 text-[12.5px] focus:outline-none focus:border-accent-400"/>
{['all',...ROLE_ORDER].map(r=>( ))}
{readOnly && (
You can view the team, but only a Super Admin can add or edit members.
)} {saveErr && (
{saveErr}
)}
Member
Role
Account access
2FA
Last active
Status
{filtered.map(u=>{ const acc = u.accounts==='*' ? 'All accounts' : `${(u.accounts||[]).length} account${(u.accounts||[]).length===1?'':'s'}`; const suspended = u.status==='suspended'; return (
{u.name} {currentUser.id===u.id && YOU}
{u.email}
{acc}
{u.twoFA ? On : Off}
{fmtAgo(u.lastActive)}
{suspended ? Suspended : Active}
{!readOnly && ( )}
); })} {filtered.length===0 &&
No members match your filter.
}
setEdit(null)} onSave={saveUser} onDelete={deleteUser} />
); } /* ---------- Roles reference ---------------------------------------- */ function RolesReference(){ const PERMS = [ { key:'view', label:'View dashboards & reports' }, { key:'columns.build', label:'Build custom columns / reports' }, { key:'campaigns.edit', label:'Pause / edit campaigns' }, { key:'automations.manage', label:'Create & run automations' }, { key:'reallocate.apply', label:'Apply budget reallocation' }, { key:'integrations.manage', label:'Manage integrations & connections' }, { key:'accounts.manage', label:'Manage ad accounts' }, { key:'users.manage', label:'Manage team members & roles' }, ]; const has = (role, key) => { const p=ROLES[role].perms; return p.includes('*')||p.includes(key); }; return (

Roles & permissions

What each permission level can do across the workspace.

Capability
{ROLE_ORDER.map(r=>(
))}
{PERMS.map(perm=>(
{perm.label}
{ROLE_ORDER.map(r=>(
{has(r,perm.key) ? : }
))}
))}
); } /* =================================================================== Activity / audit log =================================================================== */ function relTime(ts){ const s = Math.floor((Date.now()-ts)/1000); if (s<60) return s+'s ago'; if (s<3600) return Math.floor(s/60)+'m ago'; if (s<86400) return Math.floor(s/3600)+'h ago'; return Math.floor(s/86400)+'d ago'; } function AuditRow({ ev, compact }){ const m = AUDIT_META[ev.action] || { icon:'info', tone:'#8b8b80', verb:ev.action }; return (
{ev.actorName} {m.verb} {ev.target} {ev.detail && · {ev.detail}}
{relTime(ev.ts)}
); } function AuditLog({ audit }){ const [filter, setFilter] = useState('all'); const groups = [ { id:'all', label:'All' }, { id:'user', label:'Team' }, { id:'integration', label:'Connections' }, { id:'auth', label:'Sign-in' }, ]; const rows = audit.filter(e => filter==='all' || e.action.startsWith(filter)); return (

Activity log

Every change to team, roles & connections — newest first.

{groups.map(g=>( ))}
{rows.length===0 &&
No activity in this category yet.
}
{rows.map(ev=> )}
); } /* =================================================================== Branding manager (super admin only) =================================================================== */ function BrandingManager({ brand, setBrand, currentUser, pushAudit }){ const isSuper = currentUser.role === 'super_admin'; const [draft, setDraft] = useState(brand); const [err, setErr] = useState(''); const fileRef = useRef(null); useEffect(()=>{ setDraft(brand); }, [brand]); const set = (k,v)=>setDraft(d=>({...d,[k]:v})); const dirty = JSON.stringify(draft)!==JSON.stringify(brand); const onLogo = (file)=>{ setErr(''); if (!file) return; if (!/image\/(png|jpeg|svg\+xml|webp|gif)/.test(file.type)){ setErr('Use a PNG, JPG, SVG or WebP image.'); return; } if (file.size > 1.5*1024*1024){ setErr('Logo must be under 1.5MB.'); return; } const r = new FileReader(); r.onload = e=>set('logo', e.target.result); r.readAsDataURL(file); }; const save = ()=>{ setBrand(draft); if(pushAudit) pushAudit(currentUser,'integration.update','Workspace branding', draft.name); }; const ACCENTS = ['#8351ff','#2f7bff','#10b981','#06b6d4','#f59e0b','#ec4899','#ef4444','#111827']; return (

Branding

Name, tag, accent and logo — applied across the login screen, sidebar and tab title.

{!isSuper && (
Only a Super Admin can change branding.
)}
{/* live preview */}
Preview
{/* logo */}
Logo
onLogo(e.target.files[0])}/> fileRef.current.click()}>Upload logo {draft.logo && }
PNG/SVG · square · < 1.5MB. Falls back to the default mark.
{/* fields */}
set('name',e.target.value)} placeholder="Relay" className={inputCls}/> set('tag',e.target.value)} placeholder="MCP" className={inputCls}/>
Accent color
{ACCENTS.map(c=>(
{err &&
{err}
}
Save branding {dirty && setDraft(brand)}>Discard}
); } /* =================================================================== Ad accounts manager — superadmin chooses which accounts to pull =================================================================== */ function AccountEditDrawer({ open, account, isNew, onClose, onSave }){ const blank = { name:'', handle:'', currency:'USD', tier:'', synced:true, sources:{} }; const [draft, setDraft] = useState(blank); const [err, setErr] = useState(''); useEffect(()=>{ if(open){ setDraft(account ? {...blank, ...account, sources:{...(account.sources||{})}} : blank); setErr(''); } }, [open, account]); const set = (k,v)=>setDraft(d=>({...d,[k]:v})); const setSrc = (k,v)=>setDraft(d=>({...d, sources:{...(d.sources||{}), [k]:v}})); const save = () => { if (!draft.name.trim()) return setErr('Account name is required.'); /* Persist every mappable platform (was hardcoded to meta/google/tiktok, which silently dropped X + LinkedIn source IDs on save). */ const sources = {}; SRC_PLATFORMS.forEach(s=>{ const v=(draft.sources||{})[s.id]; if(v && String(v).trim()) sources[s.id]=String(v).trim(); }); onSave({ ...draft, name:draft.name.trim(), sources }, isNew); onClose(); }; const srcMeta = SRC_PLATFORMS; return ( {isNew?'Add ad account':'Edit ad account'}} footer={<>Cancel{isNew?'Add account':'Save changes'}}>
{err &&
{err}
}
set('name',e.target.value)} placeholder="Amasi Ash-Shamal" className={inputCls}/> set('handle',e.target.value)} placeholder="@amasi" className={inputCls}/>
set('currency',e.target.value.toUpperCase().slice(0,3))} placeholder="SAR" className={inputCls}/> set('tier',e.target.value)} placeholder="Performance" className={inputCls}/>
Pull data for this account
{draft.synced?'Synced — included in data pulls':'Paused — excluded from pulls'}
set('synced',v)}/>
Platform source IDs
{srcMeta.map(s=>(
setSrc(s.id, e.target.value)} placeholder={s.label+' · '+s.hint} className={inputCls+' font-mono text-[12px]'}/>
))}
These map the dashboard account to its external ad accounts. The sync pulls each platform's data using these IDs.
); } function AccountsManager({ accounts, setAccounts, currentUser, readOnly, pushAudit }){ const [edit, setEdit] = useState(null); const [err, setErr] = useState(''); const save = async (a, isNew) => { const payload = { name:a.name, handle:a.handle, currency:a.currency, tier:a.tier, synced:a.synced, sources:a.sources }; try { const saved = isNew ? await RelayAPI.accounts.create(payload) : await RelayAPI.accounts.update(a.id, payload); setAccounts(arr => { const i=arr.findIndex(x=>x.id===saved.id); if(i>=0){const n=[...arr];n[i]=saved;return n;} return [...arr, saved]; }); setErr(''); if (pushAudit) pushAudit(); } catch(e){ setErr(e.message || 'Could not save account.'); } }; const toggleSync = async (a) => { try { const saved = await RelayAPI.accounts.update(a.id, { synced:!a.synced }); setAccounts(arr => arr.map(x=>x.id===saved.id?saved:x)); if (pushAudit) pushAudit(); } catch(e){ setErr(e.message || 'Could not update account.'); } }; const remove = async (id) => { if (!confirm('Remove this account from the dashboard? Stored data stays in the warehouse.')) return; try { await RelayAPI.accounts.remove(id); setAccounts(arr => arr.filter(x=>x.id!==id)); if (pushAudit) pushAudit(); } catch(e){ setErr(e.message || 'Could not remove account.'); } }; const syncedCount = accounts.filter(a=>a.synced).length; return (

Ad accounts

{accounts.length} accounts · {syncedCount} pulling data. Toggle which accounts sync, or map their platform source IDs.

{!readOnly && setEdit({ account:null, isNew:true })}>Add account}
{err &&
{err}
} {readOnly &&
Only admins can manage ad accounts.
}
Account
Sources
Currency
Pull
{accounts.length===0 &&
No accounts yet. Click “Add account”.
} {accounts.map(a=>{ const src = a.sources || {}; return (
{a.name}
{a.handle||a.id}{a.tier?(' · '+a.tier):''}
{(() => { /* 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=> ) : ·; })()}
{a.currency||'—'}
toggleSync(a)} size="sm"/>
{!readOnly && }
); })}
setEdit(null)} onSave={save}/> {!readOnly && accounts.some(a=>!a.synced) && (
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.

{!readOnly && { setAdding(a=>!a); setErr(''); }}>Add feed}
{err &&
{err}
} {adding && !readOnly && (
setDraft(d=>({...d, label:e.target.value}))} placeholder="defaults to the account name" className={inputCls}/>
setDraft(d=>({...d, hookUrl:e.target.value}))} placeholder="https://portal.example.com/api/cv/hooks/…" className={inputCls}/>
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.'}

{ setAdding(false); setErr(''); }}>Cancel {busy==='new'?'Connecting…':'Add feed'}
)} {links === null ? Loading… : !links.length ? (
No client feeds yet
When a client subscribes to both dashboards, connect a feed so your team's work shows up on their side automatically — no exports, no double entry.
) : ( {links.map(l=>( ))}
Client Ad account Destination Rows sent Last push Actions
{l.label || accName(l.accountId)}
{l.status==='active'?`re-sends ${l.windowDays} days`:'paused'}
{accName(l.accountId)} {l.dimMode==='account' &&
shown as its own series
}
{(()=>{ try { return new URL(l.hookUrl).host; } catch(_){ return l.hookUrl; } })()} {l.rowsTotal} {l.lastPush ? relTime(l.lastPush) : 'never'} {l.lastError &&
{l.lastError}
}
{!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 }
)}
); } function AdminPanel({ currentUser, users, setUsers, integrations, setIntegrations, accounts, setAccounts, audit, pushAudit, brand, setBrand, onExit, onSignOut, dark, setDark }){ const canUsers = can(currentUser, 'users.manage'); const canInt = can(currentUser, 'integrations.manage'); const canAccounts = can(currentUser, 'accounts.manage'); const isSuper = currentUser.role === 'super_admin'; const navItems = [ { id:'overview', label:'Overview', icon:'sparkles', show:true }, { id:'users', label:'Team', icon:'settings', show:canUsers }, { id:'accounts', label:'Ad accounts', icon:'columns', show:canAccounts }, { id:'integrations', label:'Integrations', icon:'zap', show:true }, { id:'feeds', label:'Client feeds', icon:'link', show:canInt }, { id:'branding', label:'Branding', icon:'image', show:true }, { id:'activity', label:'Activity', icon:'play', show:true }, { id:'roles', label:'Roles', icon:'columns', show:true }, ].filter(i=>i.show); const [section, setSection] = useState(canUsers ? 'overview' : 'integrations'); return (
{/* top bar */}
Admin
Back to dashboard setDark(d=>!d)}>
{/* rail */} {/* content */}
{section==='overview' && } {section==='users' && } {section==='accounts' && } {section==='integrations' && } {section==='feeds' && } {section==='branding' && } {section==='activity' && } {section==='roles' && }
); } Object.assign(window, { AdminPanel, AdminUserMenu, Avatar, RoleBadge, AuditLog, relTime });