/* Painel Nanda — raiz do app: login (Supabase Auth + e-mail/senha), papéis, roteamento, dados.
NOTA: o login é por e-mail/senha. O gate real é Supabase Auth +
profiles.role sob RLS (ver supabase_schema.sql e docs/setup-supabase.md, passo 4); a porta
de entrada pode mudar sem mexer nele. Quando o projeto Supabase NOVO e próprio da Nanda
existir (ver README.md), conferir se vale habilitar "Entrar com Google" nele:
Authentication → Providers → Google, com um client OAuth do Google Cloud cujo redirect URI
aponte pro projeto DA NANDA — nunca reaproveitar o client/redirect URI de outro cliente. */
(function () {
const I = window.App.Icons;
const U = window.App.UI;
const D = window.App.Data;
const A = window.App.ScreensA;
const B = window.App.ScreensB;
const CFG = window.NANDA_CONFIG || {};
const BR = CFG.brand || {};
const { useState, useEffect, useRef, useCallback } = React;
const ROUTES = {
dashboard: A.Dashboard, pipeline: A.Pipeline, chat: A.Chat, contacts: A.Contacts,
training: B.Training, scheduling: B.Scheduling, team: B.Team, help: B.Help, settings: B.Settings,
};
const ADMIN_ONLY = { team: true, settings: true }; // UX; a trava real é a RLS por papel
// Rotas de VENDAS (exigem ver_vendas). 'help' NÃO é vendas.
const VENDAS_ROUTES = { dashboard: true, pipeline: true, chat: true, contacts: true, scheduling: true, training: true, team: true, settings: true };
const routeFromHash = () => (location.hash.replace('#/', '') || 'dashboard').split('?')[0];
class Boundary extends React.Component {
constructor(p) { super(p); this.state = { err: null }; }
static getDerivedStateFromError(err) { return { err }; }
render() {
if (this.state.err) return (
);
}
/* ─────────────── ACESSO NEGADO (logou, mas não está na allowlist) ─────────────── */
function Denied({ email, onLogout }) {
return (
Acesso não autorizado
A conta {email ? {email} : 'usada'} não está na lista de acesso do painel da Nanda. Fale com um administrador para ser liberado.
);
}
function App() {
const supa = D.client && D.client();
const [route, setRoute] = useState(routeFromHash());
const [navOpen, setNavOpen] = useState(false); // drawer da sidebar no mobile
const [data, setData] = useState(null);
const [err, setErr] = useState(null);
// auth: 'loading' | 'anon' | 'authed' | 'denied' | 'skip'(DEMO/sem cliente)
const [auth, setAuth] = useState(supa ? 'loading' : 'skip');
const [me, setMe] = useState(null); // { email, role }
const [perms, setPerms] = useState({ verVendas: true, loaded: false }); // permissões do painel (verVendas default true = compat)
const [busy, setBusy] = useState(false);
const [authErr, setAuthErr] = useState(null);
const mountedRef = useRef(true); // vira false no unmount -> corta loads em 2º plano
const roleTokenRef = useRef(0); // invalida loads de papel obsoletos (logout / troca de sessão)
useEffect(() => () => { mountedRef.current = false; }, []);
// Carrega profiles.role em SEGUNDO PLANO, com retry/backoff. NUNCA trava o login: a sessão já entra
// como 'authed' e o papel chega depois, virando isAdmin real (Configurações/Equipe abrem). Só vira
// 'denied' quando a query RESPONDE e não há linha (allowlist real). Erro/timeout viram nova tentativa —
// o papel do admin NUNCA é perdido de vez por um timeout de 3s (bug visto em outra instalação).
const loadRole = useCallback((session) => {
if (!supa || !session || !session.user) return;
const uid = session.user.id;
const email = session.user.email;
const token = ++roleTokenRef.current;
const delays = [0, 800, 2000, 4000, 8000, 15000, 30000]; // ~1min de tentativas cobre cold start / rede ruim
const live = () => mountedRef.current && token === roleTokenRef.current;
const withTimeout = (p, ms) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('profile-timeout')), ms))]);
const attempt = async (i) => {
if (!live()) return;
try {
const { data: prof, error } = await withTimeout(
supa.from('profiles').select('email,role').eq('id', uid).maybeSingle(), 8000);
if (!live()) return;
if (error) throw error; // erro transitório (rede/RLS) -> retry, sem perder o papel
if (prof) { setMe({ id: uid, email: prof.email || email, role: prof.role, loading: false }); setAuth('authed'); return; }
setMe({ id: uid, email, role: null, loading: false }); setAuth('denied'); // resposta definitiva SEM linha
} catch (e) {
if (!live()) return;
const next = i + 1;
if (next < delays.length) setTimeout(() => { if (live()) attempt(next); }, delays[next]);
// esgotou as tentativas: segue 'authed' com papel desconhecido (isAdmin=false), sem travar;
// o próximo onAuthStateChange / refresh de token dispara loadRole de novo.
}
};
attempt(0);
}, [supa]);
useEffect(() => {
const onHash = () => setRoute(routeFromHash());
window.addEventListener('hashchange', onHash);
return () => window.removeEventListener('hashchange', onHash);
}, []);
// Bootstrap de autenticação + reação a login/logout
useEffect(() => {
if (!supa) return; // DEMO / sem cliente -> sem trava de login
let alive = true;
async function resolve(session) {
if (!alive) return;
if (!session || !session.user) { roleTokenRef.current++; setMe(null); setAuth('anon'); return; }
// Sessão válida: ENTRA JÁ (login rápido, sem esperar o profiles). Preserva o papel que já houver
// pra este mesmo usuário (evita piscar), senão marca loading. O papel real vem no loadRole (com retry).
setMe((prev) => (prev && prev.id === session.user.id)
? prev
: { id: session.user.id, email: session.user.email, role: undefined, loading: true });
setAuth('authed');
loadRole(session);
}
supa.auth.getSession()
.then(({ data }) => resolve(data.session))
.catch(() => { if (alive) setAuth((p) => (p === 'loading' ? 'anon' : p)); });
const { data: sub } = supa.auth.onAuthStateChange((_e, session) => resolve(session));
// Fallback anti-travamento: se a checagem de sessao nao resolver em alguns
// segundos (ex.: sessao antiga/expirada que trava no refresh do token), mostra
// a tela de login em vez de ficar preso em "Verificando acesso" pra sempre.
const guard = setTimeout(() => { if (alive) setAuth((p) => (p === 'loading' ? 'anon' : p)); }, 4500);
return () => { alive = false; clearTimeout(guard); if (sub && sub.subscription) sub.subscription.unsubscribe(); };
}, [supa]);
// Carrega dados só quando autorizado (ou DEMO/skip). Antes do login não faz fetch.
useEffect(() => {
if (auth !== 'authed' && auth !== 'skip') return;
let alive = true;
(async () => {
try {
// dashboard() sai do Promise.all de propósito (07/10/2026): ele
// calcula em cima do MESMO leads/sessions já paginados abaixo, sem
// round-trip novo ao Supabase — por isso espera os dois resolverem
// antes de rodar (puramente síncrono por dentro, não adiciona
// latência perceptível).
const [leads, sessions, confirm, health] = await Promise.all([
D.leads(), D.sessions(), D.confirmacoes(), D.health(),
]);
const dash = await D.dashboard(leads, sessions);
if (alive) setData({ leads, sessions, confirm, dash, health });
} catch (e) { if (alive) setErr(e); }
})();
return () => { alive = false; };
}, [auth]);
// Permissão de vendas (trava no banco). Carrega em paralelo; enquanto não
// resolve, loaded=false. Falha/sem permissão => painel assume verVendas=true
// (fail-open aqui é proposital: a RLS real do Supabase já barra o dado; isto
// é só UX).
useEffect(() => {
if (auth !== 'authed' && auth !== 'skip') { setPerms({ verVendas: true, loaded: false }); return; }
let alive = true;
(async () => {
try { const p = await D.perms(); if (alive) setPerms({ verVendas: p.verVendas !== false, loaded: true }); }
catch (e) { if (alive) setPerms({ verVendas: true, loaded: true }); }
})();
return () => { alive = false; };
}, [auth]);
const go = (r) => { location.hash = '#/' + r; setRoute(r); setNavOpen(false); };
const signIn = async (email, password) => {
if (!supa) return;
setBusy(true); setAuthErr(null);
try {
const _to = new Promise((_, rej) => setTimeout(() => rej(new Error('A conexão de login não respondeu. Tente novamente.')), 12000));
const { data, error } = await Promise.race([supa.auth.signInWithPassword({ email, password }), _to]);
if (error) { setAuthErr(String(error.message || error)); setBusy(false); return; }
// sucesso: resolve DIRETO com a sessão retornada — SEM location.reload() (o reload corria com a
// gravação da sessão e às vezes voltava pra tela de login). O papel carrega em 2º plano (loadRole).
if (data && data.session) {
const s = data.session;
// Login rápido: entra JÁ. O papel (admin?) carrega em 2º plano com retry — nunca perdido por timeout.
setMe({ id: s.user.id, email: s.user.email, role: undefined, loading: true });
setAuth('authed');
loadRole(s);
setBusy(false);
return;
}
setBusy(false);
} catch (e) { setAuthErr(String(e.message || e)); setBusy(false); }
};
const signOut = async () => { try { await supa.auth.signOut(); } catch (e) { /* noop */ } location.hash = ''; location.reload(); };
// ── Gates de acesso ──
if (auth === 'loading') return (
Verificando acesso…
);
if (auth === 'anon') return ;
if (auth === 'denied') return ;
const role = me ? me.role : 'admin'; // DEMO/skip => UI completa
const roleLoading = !!(me && me.loading); // papel ainda carregando em 2º plano (NÃO é "não-admin")
const isAdmin = role === 'admin';
let effRoute = route;
let checkingPerm = false;
// "Home": único fluxo nesta instância, então é sempre o Dashboard quando há
// permissão de vendas; sem ela, cai na Ajuda.
const homeRoute = perms.verVendas ? 'dashboard' : 'help';
if (ADMIN_ONLY[effRoute] && !isAdmin) {
// Rota admin-only: se o papel ainda carrega, NÃO redireciona — espera o papel chegar (senão o admin
// que abre/atualiza direto em #/settings cairia no dashboard). Só redireciona quando o papel resolveu
// e não é admin.
if (roleLoading) checkingPerm = true; else effRoute = homeRoute;
}
// Rotas de VENDAS: exigem ver_vendas (trava real = RLS de sessions/leads/confirmacoes).
if (VENDAS_ROUTES[effRoute] && !perms.verVendas) {
if (!perms.loaded) checkingPerm = true; else effRoute = homeRoute;
}
const Screen = ROUTES[effRoute] || A.Dashboard;
const full = effRoute === 'chat' || effRoute === 'pipeline';
return (
setNavOpen(false)} verVendas={perms.verVendas} />
{/* Barra superior mobile: hambúrguer + marca (some no desktop) */}
{BR.empresa || 'Nanda'}
{D.DEMO && (
Modo demonstração: dados de exemplo no formato do banco (nenhum dado real lido/escrito).