/* global React */ /* =========================================================== VOLTLAB · AUTH SYSTEM ─ Login / Cadastro com validação ─ Browser Fingerprinting (substituto do HWID para web) ─ JWT simulado (assinar/verificar via HMAC-SHA256 via SubtleCrypto) ─ Controle de licença por plano, expiração e dispositivos ─ Painel Admin: usuários, licenças, planos ─ Guard de rota: toda a aplicação passa pela validação antes de abrir ARQUITETURA (frontend-only para teste): ─ "Banco de dados" no localStorage (chave vl_auth_db) ─ JWT Base64 com assinatura local (seguro para demo/staging) ─ Pronto para substituir por chamadas fetch() para a API real PARA PRODUÇÃO: substituir VL_AUTH.api.* por fetch para o backend Node/Express + Prisma descrito na especificação. =========================================================== */ const { useState, useEffect, useRef, useMemo, useCallback } = React; /* ═══════════════════════════════════════════════════════════ ⚠️ FASE DE TESTES — BYPASS DE PAGAMENTO ─────────────────────────────────────────────────────────── Enquanto VL_BYPASS_PAGAMENTO === true, TODOS os módulos ficam liberados para qualquer usuário, independentemente do plano. A tela de "módulo bloqueado" e a página de upgrade NÃO são exibidas. O login/cadastro continua funcionando normalmente. 👉 Para reativar a cobrança no futuro, basta trocar para false. ═══════════════════════════════════════════════════════════ */ const VL_BYPASS_PAGAMENTO = false; window.VL_BYPASS_PAGAMENTO = VL_BYPASS_PAGAMENTO; /* ────────────────────────────────────────────────────────── BROWSER FINGERPRINT Combina propriedades estáveis do browser/dispositivo para gerar um ID único sem depender de cookies. ────────────────────────────────────────────────────────── */ async function gerarFingerprint() { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); ctx.textBaseline = 'alphabetic'; ctx.font = '14px Arial'; ctx.fillText('VoltLab🔌⚡', 2, 15); const canvasHash = canvas.toDataURL(); const components = [ navigator.userAgent, navigator.language, navigator.hardwareConcurrency, screen.width + 'x' + screen.height + 'x' + screen.colorDepth, new Date().getTimezoneOffset(), navigator.platform, canvasHash.slice(0, 80), ].join('||'); // SHA-256 via SubtleCrypto (disponível em todos browsers modernos) const msgBuffer = new TextEncoder().encode(components); const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 32); } /* ────────────────────────────────────────────────────────── JWT SIMULADO (Base64url + HMAC-SHA256) Para produção: o backend emite o JWT real ────────────────────────────────────────────────────────── */ const JWT_SECRET = 'vl_jwt_secret_2025_trocar_em_producao'; function b64url(str) { return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } function b64urlDecode(str) { return atob(str.replace(/-/g, '+').replace(/_/g, '/')); } async function signJWT(payload) { const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); const body = b64url(JSON.stringify(payload)); const data = header + '.' + body; const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(JWT_SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data)); const sigB64 = b64url(String.fromCharCode(...new Uint8Array(sig))); return data + '.' + sigB64; } async function verifyJWT(token) { try { const parts = token.split('.'); if (parts.length !== 3) return null; const [header, body, sig] = parts; const data = header + '.' + body; const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(JWT_SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'] ); const sigBytes = Uint8Array.from(b64urlDecode(sig), c => c.charCodeAt(0)); const valid = await crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(data)); if (!valid) return null; const payload = JSON.parse(b64urlDecode(body)); if (payload.exp && Date.now() > payload.exp) return null; // expirado return payload; } catch { return null; } } /* ────────────────────────────────────────────────────────── BANCO DE DADOS LOCAL (localStorage) Estrutura real do Prisma mapeada para JSON ────────────────────────────────────────────────────────── */ const DB_KEY = 'vl_auth_db'; /* ────────────────────────────────────────────────────────── PLANOS (definidos no código — fonte da verdade) ─────────────────────────────────────────────────────────── Módulos FREE: Corrente, Queda de Tensão, Dimensionamento e Taxa de Ocupação (+ Dashboard e Calculadoras sempre liberados). Os demais ('*') exigem assinatura paga. ────────────────────────────────────────────────────────── */ const MODULOS_FREE = ['dashboard', 'calculators', 'current', 'drop', 'cables', 'eletroduto']; const PLANS_SEED = [ { id: 'plan_free', nome_plano: 'Free', preco: 0, ciclo: 'mês', duracao_dias: 365, limite_dispositivos: 1, modulos: MODULOS_FREE, ativo: true, descricao: 'Corrente, Queda de Tensão, Dimensionamento e Taxa de Ocupação.', }, { id: 'plan_estudante', nome_plano: 'Estudante', preco: 19.9, ciclo: 'mês', duracao_dias: 30, limite_dispositivos: 1, modulos: ['*'], ativo: true, requer_comprovante: true, descricao: 'Acesso total. Exige comprovante de matrícula.', }, { id: 'plan_pro', nome_plano: 'Pro', preco: 49.9, ciclo: 'mês', duracao_dias: 30, limite_dispositivos: 2, modulos: ['*'], ativo: true, destaque: true, descricao: 'Acesso total + projetos ilimitados. Suporte por e-mail.', }, { id: 'plan_pro_anual', nome_plano: 'Pro Anual', preco: 499, ciclo: 'ano', duracao_dias: 365, limite_dispositivos: 2, modulos: ['*'], ativo: true, descricao: 'Tudo do Pro com ~2 meses grátis (≈ R$ 41,58/mês).', }, ]; function loadDB() { let db = null; try { const raw = localStorage.getItem(DB_KEY); if (raw) db = JSON.parse(raw); } catch {} if (!db) { db = { users: [], plans: [], licenses: [], subscriptions: [], devices: [], payments: [] }; } // Os planos são definidos no código: sempre sincroniza com PLANS_SEED // (assim mudanças de preço/módulos valem mesmo com um DB já salvo). db.plans = PLANS_SEED.map(p => ({ ...p })); return db; } function saveDB(db) { try { localStorage.setItem(DB_KEY, JSON.stringify(db)); } catch {} } function genId(prefix = '') { return prefix + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); } async function hashSenha(senha) { const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(senha + '_vl_salt')); return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2,'0')).join(''); } /* ────────────────────────────────────────────────────────── LICENÇA — garantia de licença ativa (self-healing) Evita "Licença expirada" para usuários válidos quando: • o plano Free não existe mais no DB salvo; • um upgrade do admin desativou licenças antigas; • o usuário foi criado por um caminho que não gerou licença. Sempre retorna uma licença ativa (provisiona Free se preciso). Free é gratuito, então re-provisionar é legítimo. ────────────────────────────────────────────────────────── */ function ensureActiveLicense(db, userId) { // 1) Já existe licença ativa e não expirada? let lic = db.licenses.find(l => l.user_id === userId && l.ativa && new Date(l.data_expiracao) > new Date() ); if (lic) return lic; const planFree = db.plans.find(p => p.id === 'plan_free'); const dur = planFree?.duracao_dias || 365; const exp = new Date(Date.now() + dur * 864e5).toISOString(); // 2) Reaproveita a licença Free mais recente do usuário (reativa + estende) const freeAntiga = db.licenses .filter(l => l.user_id === userId && l.plan_id === 'plan_free') .sort((a, b) => new Date(b.data_expiracao) - new Date(a.data_expiracao))[0]; if (freeAntiga) { freeAntiga.ativa = true; freeAntiga.data_expiracao = exp; freeAntiga.ultima_validacao = new Date().toISOString(); lic = freeAntiga; } else { // 3) Cria uma licença Free nova lic = { id: genId('lic_'), user_id: userId, chave_licenca: 'FREE-' + genId().toUpperCase(), hardware_id: null, ativa: true, ultima_validacao: new Date().toISOString(), data_expiracao: exp, plan_id: 'plan_free', }; db.licenses.push(lic); } // Garante uma subscription ACTIVE coerente if (!db.subscriptions.find(s => s.user_id === userId && s.status === 'ACTIVE' && new Date(s.data_expiracao) > new Date())) { db.subscriptions.push({ id: genId('sub_'), user_id: userId, plan_id: 'plan_free', status: 'ACTIVE', data_inicio: new Date().toISOString(), data_expiracao: exp, }); } return lic; } /* ────────────────────────────────────────────────────────── ARMAZENAMENTO DO TOKEN "Manter sessão" liga/desliga a persistência: • remember = true → localStorage (sobrevive a fechar o navegador) • remember = false → sessionStorage (some ao fechar a aba) getToken() lê de ambos; clearToken() limpa os dois. ────────────────────────────────────────────────────────── */ const TOKEN_KEY = 'vl_token'; function setToken(token, remember) { try { if (remember) { localStorage.setItem(TOKEN_KEY, token); sessionStorage.removeItem(TOKEN_KEY); } else { sessionStorage.setItem(TOKEN_KEY, token); localStorage.removeItem(TOKEN_KEY); } } catch {} } function getToken() { try { return localStorage.getItem(TOKEN_KEY) || sessionStorage.getItem(TOKEN_KEY); } catch { return null; } } function clearToken() { try { localStorage.removeItem(TOKEN_KEY); sessionStorage.removeItem(TOKEN_KEY); } catch {} } /* ────────────────────────────────────────────────────────── API CAMADA (simula backend — trocar por fetch em produção) ────────────────────────────────────────────────────────── */ /* ────────────────────────────────────────────────────────── MEMÓRIA TEMPORÁRIA PARA VALIDAÇÃO DE E-MAIL ────────────────────────────────────────────────────────── */ const pendingRegistrations = {}; const API_URL = 'api.php'; // Aponta para o arquivo que criamos na hospedagem /* ────────────────────────────────────────────────────────── API CAMADA (Conectado ao Banco de Dados Real) ────────────────────────────────────────────────────────── */ const VL_AUTH = { // ── SOLICITAR CÓDIGO DE REGISTRO ──────────────────────── async requestRegistrationCode({ nome, email, senha }) { try { const resp = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'request_code', email }) }); const data = await resp.json(); if (!data.ok) return { ok: false, error: data.error }; pendingRegistrations[email] = { nome, email, senha, code: data.code, expires: Date.now() + 10 * 60000 }; return { ok: true, codeForTesting: data.code }; } catch (e) { return { ok: false, error: 'Erro ao conectar com o servidor.' }; } }, // ── VERIFICAR CÓDIGO E REGISTRAR ──────────────────────── async verifyAndRegister({ email, code }) { const pending = pendingRegistrations[email]; if (!pending) return { ok: false, error: 'Solicitação não encontrada ou expirada.' }; if (Date.now() > pending.expires) return { ok: false, error: 'Código expirado. Tente novamente.' }; if (pending.code !== code) return { ok: false, error: 'Código de verificação incorreto.' }; const res = await this.register({ nome: pending.nome, email: pending.email, senha: pending.senha }); delete pendingRegistrations[email]; return res; }, // ── REGISTRO OFICIAL NO MYSQL ─────────────────────────── async register({ nome, email, senha }) { try { const resp = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'register', nome, email, senha }) }); const data = await resp.json(); if (!data.ok) return { ok: false, error: data.error }; // Sincroniza com a interface para manter licenças funcionando const db = loadDB(); if (!db.users.find(u => u.id === data.user.id)) { db.users.push({...data.user, senha_hash: 'backend_hash'}); ensureActiveLicense(db, data.user.id); saveDB(db); } return { ok: true, user: data.user }; } catch (e) { return { ok: false, error: 'Erro ao registrar usuário no banco de dados.' }; } }, // ── LOGIN OFICIAL NO MYSQL ────────────────────────────── async login({ email, senha, fingerprint, remember = false }) { try { const resp = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'login', email, senha }) }); const data = await resp.json(); if (!data.ok) return { ok: false, error: data.error }; const user = data.user; const role = user.role.toUpperCase(); // Monta o plano dinamicamente com base na resposta do banco const plan = { id: `plan_${role.toLowerCase()}`, nome_plano: role === 'PRO' ? 'Pro' : role === 'GREEN' ? 'Green' : role === 'ADMIN' ? 'Admin' : 'Free', preco: role === 'FREE' ? 0 : null, modulos: ['FREE', 'USER'].includes(role) ? MODULOS_FREE : ['*'], // Admin, Pro e Green têm tudo ['*'] data_expiracao: user.data_expiracao }; const ttl = remember ? 30 * 864e5 : 8 * 3600 * 1000; // Coloca as informações vitais dentro do Token JWT const payload = { sub: user.id, nome: user.nome, email: user.email, role: user.role, plan, fingerprint, remember, iat: Date.now(), exp: Date.now() + ttl }; const token = await signJWT(payload); return { ok: true, token, remember, user, plan }; } catch (e) { return { ok: false, error: 'Erro ao validar login no servidor.' }; } }, // ── VALIDAR TOKEN ──────────────────────────────────────── async validateToken(token, fingerprint) { const payload = await verifyJWT(token); if (!payload) return { ok: false, error: 'Sessão inválida ou expirada.' }; if (!VL_BYPASS_PAGAMENTO && payload.fingerprint && fingerprint && payload.fingerprint !== fingerprint) { return { ok: false, error: 'Dispositivo não reconhecido. Faça login novamente.' }; } // O Frontend confia totalmente no JWT e no plano que veio do banco na hora do login return { ok: true, payload: payload, plan: payload.plan }; }, // ── MÓDULOS LIBERADOS ──────────────────────────────────── modulosLiberados(plan) { if (VL_BYPASS_PAGAMENTO) return null; // Fase de testes: tudo liberado if (!plan) return MODULOS_FREE; if (plan.modulos?.includes('*')) return null; // Null significa "acesso total" return plan.modulos || MODULOS_FREE; }, // ── ADMIN: listar usuários ─────────────────────────────── adminListUsers() { const db = loadDB(); return db.users.map(u => { const subs = db.subscriptions.filter(s => s.user_id === u.id); const lics = db.licenses.filter(l => l.user_id === u.id); const devs = db.devices.filter(d => d.user_id === u.id); const activeSub = subs.find(s => s.status === 'ACTIVE' && new Date(s.data_expiracao) > new Date()); const plan = activeSub ? db.plans.find(p => p.id === activeSub.plan_id) : null; return { ...u, senha_hash: '***', subs, lics, devs, plan: plan?.nome_plano || 'Sem plano' }; }); }, // ── ADMIN: upgradar plano ──────────────────────────────── adminUpgradePlan(userId, planId) { const db = loadDB(); const plan = db.plans.find(p => p.id === planId); if (!plan) return { ok: false, error: 'Plano não encontrado.' }; // Cancelar subs anteriores db.subscriptions.filter(s => s.user_id === userId).forEach(s => s.status = 'CANCELED'); db.licenses.filter(l => l.user_id === userId).forEach(l => { l.ativa = false; }); const exp = new Date(Date.now() + plan.duracao_dias * 864e5).toISOString(); const lic = { id: genId('lic_'), user_id: userId, chave_licenca: plan.nome_plano.toUpperCase() + '-' + genId().toUpperCase().slice(0,12), hardware_id: null, ativa: true, ultima_validacao: new Date().toISOString(), data_expiracao: exp, plan_id: planId, }; db.licenses.push(lic); db.subscriptions.push({ id: genId('sub_'), user_id: userId, plan_id: planId, status: 'ACTIVE', data_inicio: new Date().toISOString(), data_expiracao: exp, }); saveDB(db); return { ok: true }; }, // ── ADMIN: remover dispositivo ─────────────────────────── adminRemoveDevice(deviceId) { const db = loadDB(); db.devices = db.devices.filter(d => d.id !== deviceId); saveDB(db); return { ok: true }; }, // ── USUÁRIO: remover próprio dispositivo ───────────────── userRemoveDevice(userId, deviceId) { const db = loadDB(); db.devices = db.devices.filter(d => !(d.id === deviceId && d.user_id === userId)); saveDB(db); }, // ── LOGOUT ─────────────────────────────────────────────── logout() { clearToken(); }, // ── TOKEN (helpers expostos para o app.jsx) ────────────── setToken, // setToken(token, remember) getToken, // lê de localStorage OU sessionStorage clearToken, }; window.VL_AUTH = VL_AUTH; // Exposto para a tela de login do app.jsx (escopos Babel separados não compartilham funções) window.VL_fingerprint = gerarFingerprint; /* ────────────────────────────────────────────────────────── ÍCONES ────────────────────────────────────────────────────────── */ const AuthIcon = ({ n, s = 18 }) => { const p = { width:s, height:s, viewBox:'0 0 24 24', fill:'none', stroke:'currentColor', strokeWidth:1.5, strokeLinecap:'round', strokeLinejoin:'round' }; const d = { user: <>, lock: <>, mail: <>, eye: <>, eyeoff: <>, shield: <>, bolt: <>, crown: <>, star: <>, check: <>, x: <>, refresh: <>, trash: <>, device: <>, admin: <>, warn: <>, info: <>, }; return {d[n]||null}; }; /* ────────────────────────────────────────────────────────── FORMULÁRIO DE LOGIN / CADASTRO ────────────────────────────────────────────────────────── */ const AuthScreen = ({ onSuccess }) => { const [mode, setMode] = useState('login'); // login | register const [form, setForm] = useState({ nome:'', email:'', senha:'', confirmar:'' }); const [showPwd, setShowPwd] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [fpReady, setFpReady] = useState(false); const fpRef = useRef(''); useEffect(() => { gerarFingerprint().then(fp => { fpRef.current = fp; setFpReady(true); }); }, []); const upd = (k, v) => setForm(f => ({ ...f, [k]: v })); const handleSubmit = async () => { setError(''); if (!fpReady) { setError('Aguardando identificação do dispositivo...'); return; } if (mode === 'register') { if (!form.nome.trim()) return setError('Informe seu nome.'); if (!form.email.includes('@')) return setError('E-mail inválido.'); if (form.senha.length < 6) return setError('Senha deve ter ao menos 6 caracteres.'); if (form.senha !== form.confirmar) return setError('Senhas não conferem.'); setLoading(true); const res = await VL_AUTH.register({ nome: form.nome, email: form.email, senha: form.senha }); setLoading(false); if (!res.ok) return setError(res.error); setMode('login'); setError(''); setForm(f => ({ ...f, senha:'', confirmar:'' })); return; } // Login if (!form.email || !form.senha) return setError('Preencha e-mail e senha.'); setLoading(true); const res = await VL_AUTH.login({ email: form.email, senha: form.senha, fingerprint: fpRef.current, remember: true }); setLoading(false); if (!res.ok) return setError(res.error); VL_AUTH.setToken(res.token, true); onSuccess({ token: res.token, user: res.user, plan: res.plan, fingerprint: fpRef.current }); }; const planColors = { Free:'var(--fg-mute)', Pro:'var(--accent)', Enterprise:'var(--warn)' }; return (
{/* Background circuitry hint */}
{[...Array(8)].map((_,i) => ( ))}
{/* Logo */}
VOLTLAB. eng
PLATAFORMA DE ENGENHARIA ELÉTRICA
{/* Card */}
{/* Tabs */}
{[['login','Entrar'],['register','Criar conta']].map(([id, lbl]) => ( ))}
{/* Fields */}
{mode === 'register' && (
upd('nome',e.target.value)} placeholder="Seu nome" onKeyDown={e=>e.key==='Enter'&&handleSubmit()} style={{ flex:1, background:'transparent', border:0, outline:0, fontSize:14, color:'var(--fg)', fontFamily:'var(--ff-body)' }}/>
)}
upd('email',e.target.value)} placeholder="engenheiro@empresa.com" onKeyDown={e=>e.key==='Enter'&&handleSubmit()} style={{ flex:1, background:'transparent', border:0, outline:0, fontSize:14, color:'var(--fg)', fontFamily:'var(--ff-body)' }}/>
upd('senha',e.target.value)} placeholder="••••••••" onKeyDown={e=>e.key==='Enter'&&handleSubmit()} style={{ flex:1, background:'transparent', border:0, outline:0, fontSize:14, color:'var(--fg)', fontFamily:'var(--ff-body)' }}/>
{mode === 'register' && (
upd('confirmar',e.target.value)} placeholder="••••••••" onKeyDown={e=>e.key==='Enter'&&handleSubmit()} style={{ flex:1, background:'transparent', border:0, outline:0, fontSize:14, color:'var(--fg)', fontFamily:'var(--ff-body)' }}/>
)}
{/* Error */} {error && (
{error}
)} {/* Submit */} {/* Fingerprint indicator */}
{fpReady ? `Dispositivo: ${fpRef.current.slice(0,12)}…` : 'Identificando dispositivo…'}
{/* Planos */} {mode === 'register' && (
PLANOS DISPONÍVEIS
{loadDB().plans.filter(p=>p.ativo).map(p => (
{p.nome_plano}
{p.preco===0 ? 'Grátis' : `R$ ${p.preco}/mês`}
{p.limite_dispositivos} dispositivo(s)
))}
Cadastro inicia no plano Free. Upgrade no painel do usuário.
)}
); }; /* ────────────────────────────────────────────────────────── PAINEL DO USUÁRIO (minha conta) ────────────────────────────────────────────────────────── */ const UserPanel = ({ session, onLogout, onClose }) => { const { user, plan } = session; const [devices, setDevices] = useState([]); const [license, setLicense] = useState(null); const loadData = useCallback(() => { const db = loadDB(); setDevices(db.devices.filter(d => d.user_id === user.id)); setLicense(db.licenses.find(l => l.user_id === user.id && l.ativa) || null); }, [user.id]); useEffect(() => { loadData(); }, [loadData]); const removeDevice = (devId) => { VL_AUTH.userRemoveDevice(user.id, devId); loadData(); }; const planColor = { plan_free:'var(--fg-mute)', plan_pro:'var(--accent)', plan_enterprise:'var(--warn)' }[plan?.id] || 'var(--fg-mute)'; const diasRestantes = license ? Math.max(0, Math.ceil((new Date(license.data_expiracao)-Date.now())/864e5)) : 0; return (
e.stopPropagation()} style={{ width:380, height:'100vh', background:'var(--bg-1)', borderLeft:'1px solid var(--border-2)', padding:24, display:'flex', flexDirection:'column', gap:20, overflowY:'auto', boxShadow:'-20px 0 60px rgba(0,0,0,.4)', }}>
Minha Conta
{/* Avatar + nome */}
{user.nome}
{user.email}
{user.role==='ADMIN' && ( ADMIN )}
{/* Plano */}
PLANO ATIVO
{plan?.nome_plano || 'Sem plano'}
{plan?.preco === 0 ? 'Gratuito' : `R$ ${plan?.preco}/mês`}
{license && ( <>
Expira: {new Date(license.data_expiracao).toLocaleDateString('pt-BR')} {diasRestantes} dias restantes
Chave: {license.chave_licenca}
)}
{/* Módulos liberados */}
MÓDULOS LIBERADOS{VL_BYPASS_PAGAMENTO ? ' · FASE DE TESTES' : ''}
{(VL_BYPASS_PAGAMENTO || plan?.modulos?.includes('*') ? ['Todos os módulos liberados'] : (plan?.modulos||[])).map(m => ( {m} ))}
{/* Dispositivos */}
DISPOSITIVOS ({devices.length}/{plan?.limite_dispositivos||1})
{devices.length === 0 &&
Nenhum dispositivo registrado.
} {devices.map(d => (
{d.hardware_id}
Último acesso: {new Date(d.ultima_conexao).toLocaleDateString('pt-BR')}
))}
{/* Logout */}
); }; /* ────────────────────────────────────────────────────────── PAINEL ADMIN ────────────────────────────────────────────────────────── */ const AdminPanel = ({ session, onClose }) => { const [tab, setTab] = useState('users'); // users | plans | devices const [users, setUsers] = useState([]); const [plans, setPlans] = useState([]); const [upgradeModal, setUpgradeModal] = useState(null); // { userId, nome } const reload = useCallback(() => { setUsers(VL_AUTH.adminListUsers()); setPlans(loadDB().plans); }, []); useEffect(reload, [reload]); const doUpgrade = (planId) => { if (!upgradeModal) return; VL_AUTH.adminUpgradePlan(upgradeModal.userId, planId); setUpgradeModal(null); reload(); }; const delDevice = (devId) => { VL_AUTH.adminRemoveDevice(devId); reload(); }; const allDevices = loadDB().devices; return (
e.stopPropagation()} style={{ width:'90vw', maxWidth:900, maxHeight:'88vh', background:'var(--bg-1)', border:'1px solid var(--border-2)', borderRadius:14, display:'flex', flexDirection:'column', overflow:'hidden', boxShadow:'0 30px 100px rgba(0,0,0,.5)', }}> {/* Header */}
Painel Admin · VoltLab
{[['users','Usuários'],['plans','Planos'],['devices','Dispositivos']].map(([id,lbl]) => ( ))}
{/* Body */}
{/* USUÁRIOS */} {tab === 'users' && (
USUÁRIOS CADASTRADOS ({users.length})
{users.length === 0 &&
Nenhum usuário.
}
{users.map(u => { const activeLic = u.lics?.find(l => l.ativa && new Date(l.data_expiracao)>new Date()); const diasR = activeLic ? Math.ceil((new Date(activeLic.data_expiracao)-Date.now())/864e5) : 0; return (
{u.nome} {u.role==='ADMIN' && ADMIN}
{u.email}
{u.plan}
{activeLic &&
{diasR}d restantes
}
ID: {u.id.slice(0,12)} Criado: {new Date(u.created_at).toLocaleDateString('pt-BR')} Dispositivos: {u.devs?.length || 0} {activeLic && Chave: {activeLic.chave_licenca.slice(0,18)}… }
); })}
)} {/* PLANOS */} {tab === 'plans' && (
PLANOS DISPONÍVEIS ({plans.length})
{plans.map(p => { const usersNoPlan = users.filter(u => u.plan === p.nome_plano).length; const cor = p.id==='plan_pro'?'var(--accent)':p.id==='plan_enterprise'?'var(--warn)':'var(--fg-mute)'; return (
{p.nome_plano}
{p.preco===0?'Grátis':`R$ ${p.preco}`} {p.preco>0&&/mês}
{p.descricao}
{[ ['Duração', `${p.duracao_dias} dias`], ['Dispositivos', `${p.limite_dispositivos}`], ['Módulos', p.modulos?.includes('*')?'Todos':`${p.modulos?.length||0}`], ['Usuários ativos', `${usersNoPlan}`], ].map(([k,v])=>(
{k} {v}
))}
); })}
)} {/* DISPOSITIVOS */} {tab === 'devices' && (
DISPOSITIVOS REGISTRADOS ({allDevices.length})
{allDevices.length === 0 &&
Nenhum dispositivo.
} {['USUÁRIO','FINGERPRINT','ÚLTIMA CONEXÃO',''].map(h=>( ))} {allDevices.map(d => { const u = users.find(u=>u.id===d.user_id); return ( ); })}
{u?.nome||'?'}
{u?.email}
{d.hardware_id} {d.sistema_operacional||'—'} {new Date(d.ultima_conexao).toLocaleString('pt-BR')}
)}
{/* Modal de upgrade */} {upgradeModal && (
setUpgradeModal(null)}>
e.stopPropagation()} style={{ background:'var(--bg-1)', border:'1px solid var(--border-2)', borderRadius:12, padding:28, width:380 }}>
Alterar plano
Usuário: {upgradeModal.nome}
{plans.filter(p=>p.ativo).map(p => ( ))}
)}
); }; /* ────────────────────────────────────────────────────────── AUTH GUARD — componente wrapper para proteger o App ────────────────────────────────────────────────────────── */ const AuthGuard = ({ children, onUnauth }) => { const [authState, setAuthState] = useState('checking'); const [session, setSession] = useState(null); const [showUserPanel, setShowUserPanel] = useState(false); const [showAdmin, setShowAdmin] = useState(false); const [upgradeModulo, setUpgradeModulo] = useState(null); // null = fechado const fpRef = useRef(''); // Escuta evento global vl:upgrade disparado por módulos bloqueados useEffect(() => { const handler = (e) => setUpgradeModulo(e.detail?.moduloId || null); window.addEventListener('vl:upgrade', handler); return () => window.removeEventListener('vl:upgrade', handler); }, []); useEffect(() => { const init = async () => { // ORDEM CRÍTICA: gera o fingerprint ANTES de validar o token, // senão validateToken recebe fingerprint vazio. const fp = await gerarFingerprint(); fpRef.current = fp; const token = VL_AUTH.getToken(); // localStorage OU sessionStorage if (!token) { setAuthState('unauth'); return; } const res = await VL_AUTH.validateToken(token, fp); if (res.ok) { setSession({ token, user: { id: res.payload.sub, nome: res.payload.nome, email: res.payload.email, role: res.payload.role }, plan: res.plan, fingerprint: fp }); setAuthState('auth'); } else { VL_AUTH.logout(); // limpa os dois storages setAuthState('unauth'); } }; init(); }, []); // Quando não autenticado, avisa o app (que volta para a tela de login). useEffect(() => { if (authState === 'unauth' && typeof onUnauth === 'function') onUnauth(); }, [authState, onUnauth]); // Expõe sessão globalmente para outros módulos verificarem módulos liberados useEffect(() => { window.VL_SESSION = session; window.VL_moduloLiberado = (id) => { if (VL_BYPASS_PAGAMENTO) return true; // fase de testes: nada bloqueado if (!session) return false; const mods = VL_AUTH.modulosLiberados(session.plan); return mods === null || mods.includes(id) || mods.includes('*'); }; // Hook global: qualquer módulo pode chamar window.VL_goUpgrade('modulo-id') window.VL_goUpgrade = (moduloId) => { window._vl_upgrade_modulo = moduloId; window.dispatchEvent(new CustomEvent('vl:upgrade', { detail: { moduloId } })); }; }, [session]); if (authState === 'checking') { return (
VERIFICANDO SESSÃO…
); } if (authState === 'unauth') { // Se o app cuida da tela de login (Landing), não renderizamos o AuthScreen: // o onUnauth (acima) já mandou o app voltar para a landing. if (typeof onUnauth === 'function') { return (
); } return ( { setSession(sess); setAuthState('auth'); }}/> ); } return ( <> {/* Injeta botões na topbar do app via portal-like approach */} setShowUserPanel(true)} onOpenAdmin={() => setShowAdmin(true)} /> {React.cloneElement(children, { session, onLogout: () => { VL_AUTH.logout(); setSession(null); setAuthState('unauth'); } })} {showUserPanel && ( { VL_AUTH.logout(); setSession(null); setAuthState('unauth'); setShowUserPanel(false); }} onClose={() => setShowUserPanel(false)} /> )} {upgradeModulo !== null && ( setUpgradeModulo(null)} /> )} ); }; /* ────────────────────────────────────────────────────────── TOPBAR INJECTOR — adiciona chips de sessão ao header Funciona via DOM insertion após o React renderizar ────────────────────────────────────────────────────────── */ const AuthTopbarInjector = ({ session, onOpenUser, onOpenAdmin }) => { const mountRef = useRef(null); useEffect(() => { // Injeta no topbar real do app ou cria ponto de montagem flutuante let target = document.querySelector('.topbar-actions') || document.querySelector('.app-header-right'); if (!target) { if (!mountRef.current) { const el = document.createElement('div'); el.id = 'vl-auth-bar'; Object.assign(el.style, { position: 'fixed', top: '8px', right: '16px', zIndex: 9990, display: 'flex', alignItems: 'center', gap: '8px', }); document.body.appendChild(el); mountRef.current = el; } target = mountRef.current; } const { user, plan } = session || {}; const planColor = { plan_free:'#888', plan_pro:'var(--accent)', plan_enterprise:'var(--warn)' }[plan?.id] || '#888'; const html = `
${user?.nome?.split(' ')[0]||'User'} ${plan?.nome_plano||'Free'}
`; target.innerHTML = html; const chip = document.getElementById('vl-auth-chip'); if (chip) chip.addEventListener('click', onOpenUser); const adminBtn = document.getElementById('vl-admin-btn'); if (adminBtn) adminBtn.addEventListener('click', onOpenAdmin); return () => { if (target) target.innerHTML = ''; }; }, [session, onOpenUser, onOpenAdmin]); return null; }; /* ────────────────────────────────────────────────────────── PÁGINA DE PLANOS / UPGRADE Exibida quando o usuário clica em um módulo bloqueado. Personalize a seção /* ★ EDITE AQUI ★ / com seus preços, PIX, links do Stripe/MercadoPago, WhatsApp, etc. ────────────────────────────────────────────────────────── */ const UpgradePage = ({ session, moduloTentado, onClose }) => { const [selected, setSelected] = useState('plan_pro'); const plans = loadDB().plans.filter(p => p.ativo); const currentPlanId = session?.plan?.id || 'plan_free'; /* ★ EDITE AQUI ★ ───────────────────────────────────────── Links de checkout da Cakto — um por plano pago. Cole as URLs reais geradas no painel da Cakto. ─────────────────────────────────────────────────────────── */ const CAKTO_LINKS = { plan_estudante: 'https://pay.cakto.com.br/SEU-LINK-ESTUDANTE', plan_pro: 'https://pay.cakto.com.br/SEU-LINK-PRO-MENSAL', plan_pro_anual: 'https://pay.cakto.com.br/SEU-LINK-PRO-ANUAL', }; /* ─────────────────────────────────────────── FIM DO EDITE */ const fmtPreco = (v) => Number(v).toFixed(2).replace('.', ','); const planColors = { plan_free: 'var(--fg-mute)', plan_estudante: 'var(--accent-2)', plan_pro: 'var(--accent)', plan_pro_anual: 'var(--warn)', }; const planIcons = { plan_free: 'star', plan_estudante: 'user', plan_pro: 'bolt', plan_pro_anual: 'crown', }; const selPlan = plans.find(p => p.id === selected); const caktoUrl = CAKTO_LINKS[selected]; const caktoConfigurado = !!caktoUrl && !caktoUrl.includes('SEU-LINK'); const assinarCakto = () => { if (!selPlan || selPlan.id === 'plan_free' || selPlan.id === currentPlanId) return; if (!caktoConfigurado) { if (window.vlToast) window.vlToast('Configure o link da Cakto deste plano em auth.jsx (CAKTO_LINKS).'); return; } window.open(caktoUrl, '_blank', 'noopener'); }; // Módulo que tentou acessar const MOD_LABELS = { drop:'Queda de Tensão', cables:'Dimensionamento de Cabos', fp:'Banco de Capacitores', short:'Curto-Circuito', trafo:'Transformadores', spda:'SPDA', light:'Iluminação', diagrams:'Diagramas Unifilares', quadros:'Quadros & Partidas', cad:'Editor CAD', infra:'Infraestrutura', }; const modLabel = MOD_LABELS[moduloTentado] || moduloTentado || 'este módulo'; return (
{/* Header */}
Módulo bloqueado — {modLabel}
Seu plano atual ({session?.plan?.nome_plano || 'Free'}) não inclui este módulo. Faça upgrade para continuar.
{/* Cards de planos */}
ESCOLHA SEU PLANO
{plans.map(p => { const cor = planColors[p.id]; const isSelected = selected === p.id; const isCurrent = currentPlanId === p.id; const isUpgrade = plans.indexOf(p) > plans.findIndex(pl => pl.id === currentPlanId); return (
!isCurrent && setSelected(p.id)} style={{ borderRadius: 12, padding: 20, cursor: isCurrent ? 'default' : 'pointer', border: `2px solid ${isSelected ? cor : isCurrent ? 'var(--border-2)' : 'var(--border)'}`, background: isSelected ? `oklch(from ${cor} l c h / 0.06)` : 'var(--bg-2)', transition: 'all .2s', position: 'relative', opacity: isCurrent ? 0.6 : 1, transform: isSelected && !isCurrent ? 'translateY(-2px)' : 'none', boxShadow: isSelected && !isCurrent ? `0 8px 32px oklch(from ${cor} l c h / 0.2)` : 'none', }}> {/* Badge */} {isCurrent && (
PLANO ATUAL
)} {p.id === 'plan_pro' && !isCurrent && (
MAIS POPULAR
)} {/* Ícone + nome */}
{p.nome_plano}
{/* Preço */}
{p.preco === 0 ? (
Grátis
) : (
R$ {fmtPreco(p.preco)} /{p.ciclo}
)}
{p.descricao}
{/* Features */}
{[ [`${p.limite_dispositivos} dispositivo${p.limite_dispositivos > 1 ? 's' : ''}`, true], [p.modulos?.includes('*') ? 'Todos os módulos' : `${p.modulos?.length || 0} módulos`, true], [`${p.duracao_dias} dias por ciclo`, true], [p.id === 'plan_free' ? 'Sem suporte' : 'Suporte por e-mail', p.id !== 'plan_free'], ].map(([txt, ok], i) => (
{txt}
))}
); })}
{/* CTA de assinatura via Cakto */} {selPlan && selPlan.id !== 'plan_free' && selPlan.id !== currentPlanId && (
ASSINAR — PLANO {selPlan.nome_plano.toUpperCase()}
{selPlan.requer_comprovante && (
O plano Estudante exige comprovante de matrícula válido. Após a compra, envie o documento para liberarmos o acesso.
)}
Você será redirecionado para o checkout seguro da Cakto.
Cartão, PIX e boleto · acesso liberado após a confirmação.
{!caktoConfigurado && (
⚙︎ Dev: defina o link em CAKTO_LINKS.{selPlan.id} (auth.jsx)
)}
)} {/* Rodapé */}
Pagamento seguro · Acesso ativado em até 24h após confirmação
); }; /* ────────────────────────────────────────────────────────── MÓDULO BLOQUEADO — card inline + dispara UpgradePage Usado dentro do renderView() do app.jsx ────────────────────────────────────────────────────────── */ const ModuloBloqueado = ({ moduloId, onUpgrade }) => { const MOD_LABELS = { drop:'Queda de Tensão', cables:'Dimensionamento de Cabos', fp:'Banco de Capacitores', short:'Curto-Circuito', trafo:'Transformadores', spda:'SPDA', light:'Iluminação', diagrams:'Diagramas Unifilares', quadros:'Quadros & Partidas', cad:'Editor CAD', infra:'Infraestrutura', }; const label = MOD_LABELS[moduloId] || moduloId; return (
{label}
Este módulo não está incluído no seu plano atual.
Faça upgrade para ter acesso completo a todos os módulos do VoltLab.
Pagamento seguro · Ativação rápida
); }; // Exporta componentes para uso no app.jsx window.VL_AuthGuard = AuthGuard; window.VL_AuthScreen = AuthScreen; window.VL_UserPanel = UserPanel; window.VL_AdminPanel = AdminPanel; window.VL_UpgradePage = UpgradePage; window.VL_ModuloBloqueado = ModuloBloqueado;