/* 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: <>
| {u?.nome||'?'} {u?.email} |
{d.hardware_id} | {d.sistema_operacional||'—'} | {new Date(d.ultima_conexao).toLocaleString('pt-BR')} |