diff --git a/moldes/consolidado/96-publico-configmap.yaml b/moldes/consolidado/96-publico-configmap.yaml index 8b2f510..e6161e7 100644 --- a/moldes/consolidado/96-publico-configmap.yaml +++ b/moldes/consolidado/96-publico-configmap.yaml @@ -370,6 +370,332 @@ data: }); })(); + convite.css: | + /* Convite para competição: página pública, sem fonte nem script de fora (a CSP só deixa 'self'). */ + main { max-width: 38rem; } + .explica { color: var(--muted); } + form { display: grid; gap: 1rem; } + fieldset { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: .75rem 1rem 1rem; display: grid; gap: .6rem; } + legend { font-weight: 700; padding: 0 .4rem; } + label { display: grid; gap: .2rem; font-size: .92rem; font-weight: 600; } + input { font: inherit; padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 6px; width: 100%; } + input:focus-visible { outline: 3px solid var(--orange); outline-offset: 1px; } + .linha-jogador { display: grid; grid-template-columns: 1fr 5.5rem auto; gap: .4rem; align-items: center; } + .linha-jogador button { padding: .4rem .6rem; } + button { font: inherit; font-weight: 700; padding: .65rem 1.1rem; border: 0; border-radius: 8px; background: var(--orange); color: #fff; cursor: pointer; } + button.secundario { background: #fff; color: var(--navy); border: 1px solid var(--line); justify-self: start; } + button:disabled { opacity: .55; cursor: not-allowed; } + .erro { color: #b42318; margin: 0; font-weight: 600; } + .ok { background: #e7f6ec; border-left: 4px solid #067647; padding: 1rem 1.2rem; } + #situacao.invalido { background: #fff1e3; border-left: 4px solid var(--orange); padding: 1rem 1.2rem; } + + convite.html: | + + + + + + + + Convite para competição — Athletic Map + + + + +
Athletic Map
+
+

Convite para competição

+

Abrindo o convite…

+ + +
+ + + + + + convite.js: | + /* =========================================================================== + * Convite para competição — a página que a OUTRA escola abre pelo link. + * + * Sem login: o token no endereço é o único segredo. A página pergunta ao + * servidor a que o convite se refere e, se ele vale, deixa a pessoa mandar o + * time com os jogadores (nome e camisa). O time chega PENDENTE: quem organiza + * confere e aprova. + * + * Antes de enviar, a verificação anti-robô (desafio.js): pede um desafio ao + * servidor, resolve no navegador ("Verificando…") e manda a prova junto com o + * time, em `prova`. Um desafio por tentativa: ele é de uso único. + * + * Tudo o que vem do servidor entra na página com `textContent` — nunca como + * HTML. O script é externo porque a CSP é `script-src 'self'`. + * =========================================================================== */ + (function () { + "use strict"; + + var global = window; + + var TOKEN = /^[A-Za-z0-9_-]{20,64}$/; + var MAX_JOGADORES = 60; + + var token = decodeURIComponent((location.pathname.split("/").filter(Boolean).pop() || "")); + var base = "/api/public/competicoes/convites/" + encodeURIComponent(token); + + var titulo = document.getElementById("titulo"); + var situacao = document.getElementById("situacao"); + var formulario = document.getElementById("formulario"); + var listaJogadores = document.getElementById("jogadores"); + var erro = document.getElementById("erro"); + var enviar = document.getElementById("enviar"); + var ROTULO_ENVIAR = enviar.textContent; + + function recusar(texto) { + situacao.textContent = texto; + situacao.className = "invalido"; + formulario.hidden = true; + } + + function mostrarErro(texto) { + erro.textContent = texto; + erro.hidden = !texto; + } + + function linhaDeJogador() { + var linha = document.createElement("div"); + linha.className = "linha-jogador"; + + var nome = document.createElement("input"); + nome.placeholder = "Nome do jogador"; + nome.maxLength = 120; + nome.setAttribute("aria-label", "Nome do jogador"); + nome.setAttribute("data-campo", "nome"); + + var camisa = document.createElement("input"); + camisa.type = "number"; + camisa.min = "0"; + camisa.max = "999"; + camisa.placeholder = "nº"; + camisa.setAttribute("aria-label", "Número da camisa"); + camisa.setAttribute("data-campo", "camisa"); + + var tirar = document.createElement("button"); + tirar.type = "button"; + tirar.className = "secundario"; + tirar.textContent = "×"; + tirar.setAttribute("aria-label", "Remover jogador"); + tirar.addEventListener("click", function () { linha.remove(); }); + + linha.appendChild(nome); + linha.appendChild(camisa); + linha.appendChild(tirar); + listaJogadores.appendChild(linha); + return linha; + } + + function valor(id) { + var v = document.getElementById(id).value.trim(); + return v === "" ? null : v; + } + + function montarCorpo() { + var jogadores = []; + var linhas = listaJogadores.querySelectorAll(".linha-jogador"); + for (var i = 0; i < linhas.length; i++) { + var nome = linhas[i].querySelector('[data-campo="nome"]').value.trim(); + if (!nome) continue; + var c = linhas[i].querySelector('[data-campo="camisa"]').value; + jogadores.push({ nome: nome, numeroCamisa: c === "" ? null : parseInt(c, 10) }); + } + return { + nome: valor("nome"), + escola: valor("escola"), + cidade: valor("cidade"), + contatoNome: valor("contatoNome"), + contatoTelefone: valor("contatoTelefone"), + contatoEmail: valor("contatoEmail"), + jogadores: jogadores + }; + } + + function lerResposta(r) { + return r.json().catch(function () { return {}; }).then(function (corpo) { return { status: r.status, corpo: corpo }; }); + } + + function aoEnviar(ev) { + ev.preventDefault(); + mostrarErro(""); + var corpo = montarCorpo(); + if (!corpo.nome) { mostrarErro("Informe o nome do time."); return; } + if (corpo.jogadores.length > MAX_JOGADORES) { mostrarErro("No máximo " + MAX_JOGADORES + " jogadores."); return; } + enviar.disabled = true; + enviar.textContent = "Verificando…"; + enviar.setAttribute("aria-busy", "true"); + + function voltar(texto) { + enviar.disabled = false; + enviar.textContent = ROTULO_ENVIAR; + enviar.removeAttribute("aria-busy"); + mostrarErro(texto); + } + + fetch(base + "/desafio").then(lerResposta).then(function (r) { + if (r.status !== 200) { + var e = new Error("recusado"); + e.detalhe = (r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes."; + throw e; + } + if (!global.AtmDesafio) throw new Error("sem-crypto"); + return global.AtmDesafio.resolver(r.corpo); + }).then(function (prova) { + corpo.prova = prova; + enviar.textContent = "Enviando…"; + return fetch(base + "/times", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(corpo) + }).then(lerResposta); + }).then(function (r) { + if (r.status >= 200 && r.status < 300) { + formulario.hidden = true; + titulo.textContent = "Time enviado"; + situacao.textContent = "Recebemos o time \"" + corpo.nome + "\". A organização vai conferir os dados e aprovar. Você já pode fechar esta página."; + situacao.className = "ok"; + return; + } + voltar((r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes."); + }).catch(function (e) { + if (e && e.detalhe) { voltar(e.detalhe); return; } + if (e && (e.message === "sem-crypto" || e.message === "desafio-invalido" || e.message === "sem-solucao")) { + voltar("Não foi possível fazer a verificação anti-robô neste navegador. Atualize a página ou use outro navegador."); + return; + } + voltar("Sem conexão. Confira a internet e tente de novo."); + }); + } + + function iniciar() { + if (!TOKEN.test(token)) { recusar("Este endereço de convite não é válido."); return; } + fetch(base).then(lerResposta).then(function (r) { + var info = r.corpo || {}; + if (!info.valido) { + recusar(info.motivo ? "Não dá para usar este convite: " + info.motivo + "." : "Este convite não está disponível."); + return; + } + titulo.textContent = "Convite: " + (info.competicao || "competição"); + situacao.textContent = (info.temporada ? "Temporada " + info.temporada + ". " : "") + + "Restam " + info.vagasRestantes + " vaga(s) neste convite."; + situacao.className = ""; + formulario.hidden = false; + for (var i = 0; i < 5; i++) linhaDeJogador(); + }).catch(function () { + recusar("Não foi possível abrir o convite agora. Tente de novo em instantes."); + }); + } + + document.getElementById("maisJogador").addEventListener("click", function () { + if (listaJogadores.querySelectorAll(".linha-jogador").length < MAX_JOGADORES) linhaDeJogador().querySelector("input").focus(); + }); + formulario.addEventListener("submit", aoEnviar); + iniciar(); + })(); + + desafio.js: | + /* =========================================================================== + * Verificação anti-robô das páginas públicas — prova de trabalho no estilo + * ALTCHA, sem serviço de terceiro (nada de quem preenche sai do Athletic Map). + * + * O servidor manda { algoritmo: "SHA-256", desafio, sal, assinatura, maximo }. + * Aqui se procura o número n (0..maximo) tal que sha256(sal + n) == desafio, e + * devolve-se { numero, sal, assinatura } para ir junto no envio. Em média são + * maximo/2 hashes: ~1–2 s num celular com maximo = 150 000. + * + * Usa crypto.subtle (só existe em HTTPS — e as páginas públicas são HTTPS). + * Os hashes saem em lotes paralelos: um `await` por hash custaria mais que o + * próprio hash. Script externo porque a CSP é `script-src 'self'`. + * + * O teste do servidor é DesafioDoConvite (servico-campeonato). + * =========================================================================== */ + (function (global) { + "use strict"; + + var LOTE = 1000; + var HEX = /^[0-9a-f]{64}$/; + + function bytesDoHex(h) { + var b = new Uint8Array(h.length / 2); + for (var i = 0; i < b.length; i++) b[i] = parseInt(h.substr(i * 2, 2), 16); + return b; + } + + function iguais(buf, alvo) { + var a = new Uint8Array(buf); + if (a.length !== alvo.length) return false; + for (var i = 0; i < a.length; i++) if (a[i] !== alvo[i]) return false; + return true; + } + + /** + * @param {{algoritmo:string, desafio:string, sal:string, assinatura:string, maximo:number}} d + * @returns {Promise<{numero:number, sal:string, assinatura:string}>} + * rejeita com Error("sem-crypto"), Error("desafio-invalido") ou Error("sem-solucao") + */ + function resolver(d) { + var subtle = global.crypto && global.crypto.subtle; + var Codificador = global.TextEncoder; + if (!subtle || !Codificador) return Promise.reject(new Error("sem-crypto")); + if (!d || d.algoritmo !== "SHA-256" || !HEX.test(d.desafio || "") || typeof d.sal !== "string" + || typeof d.maximo !== "number" || d.maximo < 0 || d.maximo > 5000000) { + return Promise.reject(new Error("desafio-invalido")); + } + var alvo = bytesDoHex(d.desafio); + var cod = new Codificador(); + + function lote(inicio) { + if (inicio > d.maximo) return Promise.reject(new Error("sem-solucao")); + var fim = Math.min(inicio + LOTE - 1, d.maximo); + var hashes = []; + for (var n = inicio; n <= fim; n++) hashes.push(subtle.digest("SHA-256", cod.encode(d.sal + n))); + return Promise.all(hashes).then(function (hs) { + for (var i = 0; i < hs.length; i++) { + if (iguais(hs[i], alvo)) return { numero: inicio + i, sal: d.sal, assinatura: d.assinatura }; + } + return lote(fim + 1); + }); + } + return lote(0); + } + + global.AtmDesafio = { resolver: resolver }; + })(window); + fontes--fontes.css: | /* ============================================================ * As fontes da landing page, servidas POR NOS. @@ -3430,7 +3756,7 @@ data: - + @@ -3912,7 +4238,8 @@ data: + (whatsapp(c.whatsapp) ? 'WhatsApp' : "") + (c.email ? 'E-mail' : "") + (f.faturaVencida ? 'Fatura vencida' : "") - + "" + + (c.tenant ? '' : "") + + '

' + (alertas ? '" : "") + '
' @@ -4433,6 +4760,42 @@ data: corpo.addEventListener("keydown", function (ev) { if (ev.key === "Enter") aoEscolher(ev); }); $("alertas").addEventListener("click", aoEscolher); + /* ── licenca do app do treinador ────────────────────────────────────────── + * Reenviar = gerar uma chave NOVA e mandar ao dono da compra. A anterior + * deixa de valer para ATIVAR (quem ja ativou o app continua entrando: o app + * guarda a escolinha, nao a chave). Por isso pede confirmacao. */ + var API_LICENCA = "/api/plataforma/painel/licencas/"; + + function reenviarLicenca(tenant, aviso) { + if (!tokenAtual) return Promise.resolve(); + if (!window.confirm("Gerar uma chave NOVA para " + tenant + " e mandar por e-mail ao dono da compra?\n\n" + + "A chave atual deixa de valer para ativar o app (quem já ativou continua entrando).")) { + return Promise.resolve(); + } + aviso.textContent = "Enviando…"; + return fetch(API_LICENCA + encodeURIComponent(tenant) + "/reenviar", { + method: "POST", headers: { "x-atm-admin-token": tokenAtual, "Content-Type": "application/json" }, body: "{}" + }).then(function (r) { + if (r.status === 401) throw new Error("O token expirou. Saia e entre de novo no painel."); + return r.json().then(function (d) { + if (!r.ok) throw new Error((d && d.detail) || ("O servidor recusou (" + r.status + ").")); + return d; + }); + }).then(function (d) { + aviso.textContent = (d.emailEnviado ? "Chave nova enviada para " : "Chave nova criada; o e-mail vai em instantes para ") + + (d.destino || "o dono da compra") + " (termina em " + d.finalDaChave + ")."; + }).catch(function (e) { + aviso.textContent = e && e.message && e.message !== "Failed to fetch" ? e.message : "Sem conexão com o servidor."; + }); + } + + $("fichaCorpo").addEventListener("click", function (ev) { + var b = ev.target.closest ? ev.target.closest("[data-licenca]") : null; + if (!b) return; + b.disabled = true; + reenviarLicenca(b.getAttribute("data-licenca"), $("licencaAviso")).then(function () { b.disabled = false; }); + }); + $("fecharFicha").addEventListener("click", fecharFicha); $("ficha").addEventListener("click", function (ev) { if (ev.target === $("ficha")) fecharFicha(); }); document.addEventListener("keydown", function (ev) { if (ev.key === "Escape") fecharFicha(); }); diff --git a/tenants/plataforma/96-publico-configmap.yaml b/tenants/plataforma/96-publico-configmap.yaml index fcebcae..5dc77f4 100644 --- a/tenants/plataforma/96-publico-configmap.yaml +++ b/tenants/plataforma/96-publico-configmap.yaml @@ -370,6 +370,332 @@ data: }); })(); + convite.css: | + /* Convite para competição: página pública, sem fonte nem script de fora (a CSP só deixa 'self'). */ + main { max-width: 38rem; } + .explica { color: var(--muted); } + form { display: grid; gap: 1rem; } + fieldset { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: .75rem 1rem 1rem; display: grid; gap: .6rem; } + legend { font-weight: 700; padding: 0 .4rem; } + label { display: grid; gap: .2rem; font-size: .92rem; font-weight: 600; } + input { font: inherit; padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 6px; width: 100%; } + input:focus-visible { outline: 3px solid var(--orange); outline-offset: 1px; } + .linha-jogador { display: grid; grid-template-columns: 1fr 5.5rem auto; gap: .4rem; align-items: center; } + .linha-jogador button { padding: .4rem .6rem; } + button { font: inherit; font-weight: 700; padding: .65rem 1.1rem; border: 0; border-radius: 8px; background: var(--orange); color: #fff; cursor: pointer; } + button.secundario { background: #fff; color: var(--navy); border: 1px solid var(--line); justify-self: start; } + button:disabled { opacity: .55; cursor: not-allowed; } + .erro { color: #b42318; margin: 0; font-weight: 600; } + .ok { background: #e7f6ec; border-left: 4px solid #067647; padding: 1rem 1.2rem; } + #situacao.invalido { background: #fff1e3; border-left: 4px solid var(--orange); padding: 1rem 1.2rem; } + + convite.html: | + + + + + + + + Convite para competição — Athletic Map + + + + +
Athletic Map
+
+

Convite para competição

+

Abrindo o convite…

+ + +
+ + + + + + convite.js: | + /* =========================================================================== + * Convite para competição — a página que a OUTRA escola abre pelo link. + * + * Sem login: o token no endereço é o único segredo. A página pergunta ao + * servidor a que o convite se refere e, se ele vale, deixa a pessoa mandar o + * time com os jogadores (nome e camisa). O time chega PENDENTE: quem organiza + * confere e aprova. + * + * Antes de enviar, a verificação anti-robô (desafio.js): pede um desafio ao + * servidor, resolve no navegador ("Verificando…") e manda a prova junto com o + * time, em `prova`. Um desafio por tentativa: ele é de uso único. + * + * Tudo o que vem do servidor entra na página com `textContent` — nunca como + * HTML. O script é externo porque a CSP é `script-src 'self'`. + * =========================================================================== */ + (function () { + "use strict"; + + var global = window; + + var TOKEN = /^[A-Za-z0-9_-]{20,64}$/; + var MAX_JOGADORES = 60; + + var token = decodeURIComponent((location.pathname.split("/").filter(Boolean).pop() || "")); + var base = "/api/public/competicoes/convites/" + encodeURIComponent(token); + + var titulo = document.getElementById("titulo"); + var situacao = document.getElementById("situacao"); + var formulario = document.getElementById("formulario"); + var listaJogadores = document.getElementById("jogadores"); + var erro = document.getElementById("erro"); + var enviar = document.getElementById("enviar"); + var ROTULO_ENVIAR = enviar.textContent; + + function recusar(texto) { + situacao.textContent = texto; + situacao.className = "invalido"; + formulario.hidden = true; + } + + function mostrarErro(texto) { + erro.textContent = texto; + erro.hidden = !texto; + } + + function linhaDeJogador() { + var linha = document.createElement("div"); + linha.className = "linha-jogador"; + + var nome = document.createElement("input"); + nome.placeholder = "Nome do jogador"; + nome.maxLength = 120; + nome.setAttribute("aria-label", "Nome do jogador"); + nome.setAttribute("data-campo", "nome"); + + var camisa = document.createElement("input"); + camisa.type = "number"; + camisa.min = "0"; + camisa.max = "999"; + camisa.placeholder = "nº"; + camisa.setAttribute("aria-label", "Número da camisa"); + camisa.setAttribute("data-campo", "camisa"); + + var tirar = document.createElement("button"); + tirar.type = "button"; + tirar.className = "secundario"; + tirar.textContent = "×"; + tirar.setAttribute("aria-label", "Remover jogador"); + tirar.addEventListener("click", function () { linha.remove(); }); + + linha.appendChild(nome); + linha.appendChild(camisa); + linha.appendChild(tirar); + listaJogadores.appendChild(linha); + return linha; + } + + function valor(id) { + var v = document.getElementById(id).value.trim(); + return v === "" ? null : v; + } + + function montarCorpo() { + var jogadores = []; + var linhas = listaJogadores.querySelectorAll(".linha-jogador"); + for (var i = 0; i < linhas.length; i++) { + var nome = linhas[i].querySelector('[data-campo="nome"]').value.trim(); + if (!nome) continue; + var c = linhas[i].querySelector('[data-campo="camisa"]').value; + jogadores.push({ nome: nome, numeroCamisa: c === "" ? null : parseInt(c, 10) }); + } + return { + nome: valor("nome"), + escola: valor("escola"), + cidade: valor("cidade"), + contatoNome: valor("contatoNome"), + contatoTelefone: valor("contatoTelefone"), + contatoEmail: valor("contatoEmail"), + jogadores: jogadores + }; + } + + function lerResposta(r) { + return r.json().catch(function () { return {}; }).then(function (corpo) { return { status: r.status, corpo: corpo }; }); + } + + function aoEnviar(ev) { + ev.preventDefault(); + mostrarErro(""); + var corpo = montarCorpo(); + if (!corpo.nome) { mostrarErro("Informe o nome do time."); return; } + if (corpo.jogadores.length > MAX_JOGADORES) { mostrarErro("No máximo " + MAX_JOGADORES + " jogadores."); return; } + enviar.disabled = true; + enviar.textContent = "Verificando…"; + enviar.setAttribute("aria-busy", "true"); + + function voltar(texto) { + enviar.disabled = false; + enviar.textContent = ROTULO_ENVIAR; + enviar.removeAttribute("aria-busy"); + mostrarErro(texto); + } + + fetch(base + "/desafio").then(lerResposta).then(function (r) { + if (r.status !== 200) { + var e = new Error("recusado"); + e.detalhe = (r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes."; + throw e; + } + if (!global.AtmDesafio) throw new Error("sem-crypto"); + return global.AtmDesafio.resolver(r.corpo); + }).then(function (prova) { + corpo.prova = prova; + enviar.textContent = "Enviando…"; + return fetch(base + "/times", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(corpo) + }).then(lerResposta); + }).then(function (r) { + if (r.status >= 200 && r.status < 300) { + formulario.hidden = true; + titulo.textContent = "Time enviado"; + situacao.textContent = "Recebemos o time \"" + corpo.nome + "\". A organização vai conferir os dados e aprovar. Você já pode fechar esta página."; + situacao.className = "ok"; + return; + } + voltar((r.corpo && r.corpo.detail) || "Não foi possível enviar agora. Tente de novo em instantes."); + }).catch(function (e) { + if (e && e.detalhe) { voltar(e.detalhe); return; } + if (e && (e.message === "sem-crypto" || e.message === "desafio-invalido" || e.message === "sem-solucao")) { + voltar("Não foi possível fazer a verificação anti-robô neste navegador. Atualize a página ou use outro navegador."); + return; + } + voltar("Sem conexão. Confira a internet e tente de novo."); + }); + } + + function iniciar() { + if (!TOKEN.test(token)) { recusar("Este endereço de convite não é válido."); return; } + fetch(base).then(lerResposta).then(function (r) { + var info = r.corpo || {}; + if (!info.valido) { + recusar(info.motivo ? "Não dá para usar este convite: " + info.motivo + "." : "Este convite não está disponível."); + return; + } + titulo.textContent = "Convite: " + (info.competicao || "competição"); + situacao.textContent = (info.temporada ? "Temporada " + info.temporada + ". " : "") + + "Restam " + info.vagasRestantes + " vaga(s) neste convite."; + situacao.className = ""; + formulario.hidden = false; + for (var i = 0; i < 5; i++) linhaDeJogador(); + }).catch(function () { + recusar("Não foi possível abrir o convite agora. Tente de novo em instantes."); + }); + } + + document.getElementById("maisJogador").addEventListener("click", function () { + if (listaJogadores.querySelectorAll(".linha-jogador").length < MAX_JOGADORES) linhaDeJogador().querySelector("input").focus(); + }); + formulario.addEventListener("submit", aoEnviar); + iniciar(); + })(); + + desafio.js: | + /* =========================================================================== + * Verificação anti-robô das páginas públicas — prova de trabalho no estilo + * ALTCHA, sem serviço de terceiro (nada de quem preenche sai do Athletic Map). + * + * O servidor manda { algoritmo: "SHA-256", desafio, sal, assinatura, maximo }. + * Aqui se procura o número n (0..maximo) tal que sha256(sal + n) == desafio, e + * devolve-se { numero, sal, assinatura } para ir junto no envio. Em média são + * maximo/2 hashes: ~1–2 s num celular com maximo = 150 000. + * + * Usa crypto.subtle (só existe em HTTPS — e as páginas públicas são HTTPS). + * Os hashes saem em lotes paralelos: um `await` por hash custaria mais que o + * próprio hash. Script externo porque a CSP é `script-src 'self'`. + * + * O teste do servidor é DesafioDoConvite (servico-campeonato). + * =========================================================================== */ + (function (global) { + "use strict"; + + var LOTE = 1000; + var HEX = /^[0-9a-f]{64}$/; + + function bytesDoHex(h) { + var b = new Uint8Array(h.length / 2); + for (var i = 0; i < b.length; i++) b[i] = parseInt(h.substr(i * 2, 2), 16); + return b; + } + + function iguais(buf, alvo) { + var a = new Uint8Array(buf); + if (a.length !== alvo.length) return false; + for (var i = 0; i < a.length; i++) if (a[i] !== alvo[i]) return false; + return true; + } + + /** + * @param {{algoritmo:string, desafio:string, sal:string, assinatura:string, maximo:number}} d + * @returns {Promise<{numero:number, sal:string, assinatura:string}>} + * rejeita com Error("sem-crypto"), Error("desafio-invalido") ou Error("sem-solucao") + */ + function resolver(d) { + var subtle = global.crypto && global.crypto.subtle; + var Codificador = global.TextEncoder; + if (!subtle || !Codificador) return Promise.reject(new Error("sem-crypto")); + if (!d || d.algoritmo !== "SHA-256" || !HEX.test(d.desafio || "") || typeof d.sal !== "string" + || typeof d.maximo !== "number" || d.maximo < 0 || d.maximo > 5000000) { + return Promise.reject(new Error("desafio-invalido")); + } + var alvo = bytesDoHex(d.desafio); + var cod = new Codificador(); + + function lote(inicio) { + if (inicio > d.maximo) return Promise.reject(new Error("sem-solucao")); + var fim = Math.min(inicio + LOTE - 1, d.maximo); + var hashes = []; + for (var n = inicio; n <= fim; n++) hashes.push(subtle.digest("SHA-256", cod.encode(d.sal + n))); + return Promise.all(hashes).then(function (hs) { + for (var i = 0; i < hs.length; i++) { + if (iguais(hs[i], alvo)) return { numero: inicio + i, sal: d.sal, assinatura: d.assinatura }; + } + return lote(fim + 1); + }); + } + return lote(0); + } + + global.AtmDesafio = { resolver: resolver }; + })(window); + fontes--fontes.css: | /* ============================================================ * As fontes da landing page, servidas POR NOS. @@ -3430,7 +3756,7 @@ data: - + @@ -3912,7 +4238,8 @@ data: + (whatsapp(c.whatsapp) ? 'WhatsApp' : "") + (c.email ? 'E-mail' : "") + (f.faturaVencida ? 'Fatura vencida' : "") - + "
" + + (c.tenant ? '' : "") + + '

' + (alertas ? '" : "") + '
' @@ -4433,6 +4760,42 @@ data: corpo.addEventListener("keydown", function (ev) { if (ev.key === "Enter") aoEscolher(ev); }); $("alertas").addEventListener("click", aoEscolher); + /* ── licenca do app do treinador ────────────────────────────────────────── + * Reenviar = gerar uma chave NOVA e mandar ao dono da compra. A anterior + * deixa de valer para ATIVAR (quem ja ativou o app continua entrando: o app + * guarda a escolinha, nao a chave). Por isso pede confirmacao. */ + var API_LICENCA = "/api/plataforma/painel/licencas/"; + + function reenviarLicenca(tenant, aviso) { + if (!tokenAtual) return Promise.resolve(); + if (!window.confirm("Gerar uma chave NOVA para " + tenant + " e mandar por e-mail ao dono da compra?\n\n" + + "A chave atual deixa de valer para ativar o app (quem já ativou continua entrando).")) { + return Promise.resolve(); + } + aviso.textContent = "Enviando…"; + return fetch(API_LICENCA + encodeURIComponent(tenant) + "/reenviar", { + method: "POST", headers: { "x-atm-admin-token": tokenAtual, "Content-Type": "application/json" }, body: "{}" + }).then(function (r) { + if (r.status === 401) throw new Error("O token expirou. Saia e entre de novo no painel."); + return r.json().then(function (d) { + if (!r.ok) throw new Error((d && d.detail) || ("O servidor recusou (" + r.status + ").")); + return d; + }); + }).then(function (d) { + aviso.textContent = (d.emailEnviado ? "Chave nova enviada para " : "Chave nova criada; o e-mail vai em instantes para ") + + (d.destino || "o dono da compra") + " (termina em " + d.finalDaChave + ")."; + }).catch(function (e) { + aviso.textContent = e && e.message && e.message !== "Failed to fetch" ? e.message : "Sem conexão com o servidor."; + }); + } + + $("fichaCorpo").addEventListener("click", function (ev) { + var b = ev.target.closest ? ev.target.closest("[data-licenca]") : null; + if (!b) return; + b.disabled = true; + reenviarLicenca(b.getAttribute("data-licenca"), $("licencaAviso")).then(function () { b.disabled = false; }); + }); + $("fecharFicha").addEventListener("click", fecharFicha); $("ficha").addEventListener("click", function (ev) { if (ev.target === $("ficha")) fecharFicha(); }); document.addEventListener("keydown", function (ev) { if (ev.key === "Escape") fecharFicha(); });