/* Auth, roles, branding + Login =====================================
"Relay" — multi-platform ad command. Authentication and all team /
connection data now live in the MySQL backend (see /api). This file
keeps the role matrix (mirrored server-side), the login screen, and
shared identity UI. Theme + branding still persist to localStorage
(cosmetic only).
=================================================================== */
/* ---------- localStorage (cosmetic prefs only) -------------------- */
const LS = {
theme: 'relay_theme_v1',
brand: 'relay_brand_v1',
};
function loadLS(key, fallback){
try { const v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; }
catch(e){ return fallback; }
}
function saveLS(key, val){
try { localStorage.setItem(key, JSON.stringify(val)); } catch(e){}
}
/* ---------- Roles & permissions (UI mirror of server rbac.php) ----- */
const ROLES = {
super_admin: {
label:'Super Admin', tone:'#8351ff', rank:5,
blurb:'Full control. Manage users, roles, integrations and every account.',
perms:['*'],
},
admin: {
label:'Admin', tone:'#2f7bff', rank:4,
blurb:'Manage integrations & accounts and act on campaigns. Cannot manage users.',
perms:['integrations.manage','accounts.manage','campaigns.edit','automations.manage','reallocate.apply','columns.build','settings.manage','mediaplan.manage','reports.build','view'],
},
manager: {
label:'Manager', tone:'#10b981', rank:3,
blurb:'Edit campaigns, run automations and reallocate budget on assigned accounts.',
perms:['campaigns.edit','automations.manage','reallocate.apply','columns.build','reports.build','view'],
},
analyst: {
label:'Analyst', tone:'#06b6d4', rank:2,
blurb:'View performance and build reports / custom columns. No live edits.',
perms:['columns.build','reports.build','view'],
},
viewer: {
label:'Viewer', tone:'#8b8b80', rank:1,
blurb:'Read-only access to assigned accounts.',
perms:['view'],
},
reporter: {
label:'Reporter', tone:'#f59e0b', rank:1,
blurb:'Report Builder only — build, export and import report data for assigned accounts. No dashboards or settings.',
perms:['reports.build','data.import'],
},
};
const ROLE_ORDER = ['super_admin','admin','manager','analyst','viewer','reporter'];
function can(user, perm){
if (!user || user.status === 'suspended') return false;
const perms = (ROLES[user.role] || {}).perms || [];
return perms.includes('*') || perms.includes(perm);
}
/* Accounts a user may see. '*' = all. */
function accessibleAccounts(user){
if (!user) return [];
if (user.accounts === '*' || (Array.isArray(user.accounts) && user.accounts.includes('*')))
return ACCOUNTS;
return ACCOUNTS.filter(a => (user.accounts||[]).includes(a.id));
}
const AVATAR_TONES = ['#8351ff','#2f7bff','#10b981','#06b6d4','#f59e0b','#ec4899','#ef4444','#6366f1'];
/* ---------- Audit metadata (icons/verbs for the activity log) ------ */
const AUDIT_META = {
'auth.signin': { icon:'zap', tone:'#06b6d4', verb:'signed in' },
'auth.signout': { icon:'link-break', tone:'#8b8b80', verb:'signed out' },
'user.create': { icon:'plus', tone:'#10b981', verb:'invited' },
'user.update': { icon:'settings', tone:'#2f7bff', verb:'updated' },
'user.delete': { icon:'x', tone:'#ef4444', verb:'removed' },
'account.create': { icon:'plus', tone:'#10b981', verb:'added account' },
'account.update': { icon:'settings', tone:'#2f7bff', verb:'updated account' },
'account.delete': { icon:'x', tone:'#ef4444', verb:'removed account' },
'integration.update': { icon:'settings', tone:'#8351ff', verb:'updated connection' },
'integration.connect': { icon:'zap', tone:'#10b981', verb:'connected' },
'integration.disconnect': { icon:'link-break', tone:'#f59e0b', verb:'disconnected' },
'integration.test': { icon:'play', tone:'#06b6d4', verb:'tested' },
'data.import': { icon:'plus', tone:'#10b981', verb:'imported data to' },
};
/* ---------- Branding (cosmetic, localStorage) --------------------- */
const BRAND_DEFAULT = { name:'Relay', tag:'MCP', accent:'#8351ff', logo:null };
function loadBranding(){ return { ...BRAND_DEFAULT, ...(loadLS(LS.brand, {})||{}) }; }
function saveBranding(b){ saveLS(LS.brand, b); return b; }
/* Which platforms have a live (connected) feed, per integrations. */
function connectedPlatforms(integrations){
const set = {};
(integrations||[]).forEach(i => {
if (i.status === 'connected') (i.platforms||[]).forEach(p => { set[p] = i.health || 'healthy'; });
});
return set;
}
/* ---------- Avatar ------------------------------------------------- */
function initialsOf(name){
return String(name||'?').trim().split(/\s+/).map(s=>s[0]).slice(0,2).join('').toUpperCase();
}
function Avatar({ user, size=36, ring=false }){
if (!user) return null;
const tone = user.tone || '#8351ff';
return (
{initialsOf(user.name)}
{user.status==='suspended' && (
)}
);
}
function RoleBadge({ role, size='md' }){
const r = ROLES[role] || ROLES.viewer;
const cls = size==='sm' ? 'text-[10px] px-1.5 py-0.5' : 'text-[11px] px-2 py-0.5';
return (
{r.label}
);
}
/* ===================================================================
Login screen
=================================================================== */
function RelayMark({ size=34, tone='#8351ff' }){
const s = size;
return (
);
}
function BrandLogo({ brand, size=32 }){
const b = brand || loadBranding();
if (b.logo) return ;
return ;
}
function Wordmark({ light=false, size=18, brand }){
const b = brand || loadBranding();
const accent = b.accent || '#8351ff';
return (
{b.name || 'Relay'}
{b.tag &&
{b.tag}
}
);
}
/* onSignIn(email, password, remember) → Promise (rejects with {message} on failure). */
function LoginScreen({ onSignIn, brand }){
const _brand = brand || loadBranding();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [show, setShow] = useState(false);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [remember, setRemember] = useState(true);
const submit = (e) => {
e && e.preventDefault();
setError('');
if (!email.trim() || !password) { setError('Enter your email and password.'); return; }
setBusy(true);
Promise.resolve(onSignIn(email.trim(), password, remember))
.catch(err => { setError((err && err.message) || 'Sign-in failed. Try again.'); })
.finally(() => setBusy(false));
};
return (
{/* Left — brand / atmosphere */}
MCP PIPELINE · LIVE
Every ad platform, wired through one relay.
Meta, Google, TikTok, Snap and X stream into a single command surface — via MCP, direct API, or manual import. Drill, reallocate and automate without leaving the page.