chore(tenants): conteudo de publico/ atualizado

This commit is contained in:
deploy
2026-10-07 20:42:09 +00:00
parent 9f84b1d8d6
commit 177b60f909
2 changed files with 772 additions and 116 deletions
+386 -58
View File
@@ -3268,6 +3268,13 @@ data:
cursor: pointer; text-decoration: none;
}
.porta .btn { width: 100%; }
/* "usar token": o plano B fica a mao, mas sem competir com o "Entrar". */
.porta .discreto {
display: block; margin: 18px auto 0; padding: 2px 4px; border: 0; background: none;
color: var(--muted); font: inherit; font-size: 12px; text-decoration: underline; cursor: pointer;
}
.porta .discreto:hover { color: var(--navy); }
.porta .plano-b { margin-top: 10px; padding-top: 6px; border-top: 1px solid var(--line); }
.btn:hover { background: var(--orange-600); }
.btn.claro { background: var(--branco); color: var(--navy); border: 1px solid var(--line); }
.btn.claro:hover { border-color: var(--orange); color: var(--orange-600); }
@@ -3501,10 +3508,15 @@ data:
<!--
O PAINEL DO DONO.
Quem barra e o `x-atm-admin-token`, conferido no servidor em tempo constante.
Esta pagina so o guarda em `sessionStorage`: fechar a aba esquece. Nao e
`localStorage` de proposito — um token de administracao que sobrevive ao
fechamento da aba fica numa maquina que ninguem lembra que o tem.
Desde 07/10/2026 a entrada e o LOGIN do Keycloak da plataforma (realm
`athleticmap`, client `spa`, code + PKCE), e so quem tem o papel `atm_admin`
passa — o 2FA desse papel e o Keycloak quem cobra. O token de acesso vive so
em memoria (ver o cabecalho de `painel.js`).
O plano B — o `x-atm-admin-token` colado a mao — continua atras do link
"usar token", para emergencia. Esse fica em `sessionStorage`: fechar a aba
esquece. Nao e `localStorage` de proposito — um token de administracao que
sobrevive ao fechamento da aba fica numa maquina que ninguem lembra que o tem.
NAO HA ESTILO EMBUTIDO: a CSP da plataforma recusa `style=` e `<style>`, e ha
teste guardando isso na landing page. O visual vive em `painel.css`; as
@@ -3514,7 +3526,7 @@ data:
`Cache-Control: immutable` por um ano. Suba o numero a cada publicacao.
-->
<link rel="stylesheet" href="/publico/fontes/fontes.css">
<link rel="stylesheet" href="/publico/painel.css?v=2.86">
<link rel="stylesheet" href="/publico/painel.css?v=2.87">
</head>
<body>
@@ -3540,18 +3552,32 @@ data:
<main class="wrap">
<!-- A porta. Aparece quando nao ha token guardado, e some depois. -->
<!-- A porta. Aparece quando nao ha sessao, e some depois. -->
<section id="porta" class="cartao porta">
<h1>Entrar</h1>
<p class="ajuda">
Cole o token de administracao. Ele fica guardado ate voce fechar a aba.
Entre com a sua conta de administrador da plataforma.
</p>
<form id="formEntrar">
<button type="button" id="entrarLogin" class="btn">Entrar</button>
<p id="erroEntrar" class="erro oculto"></p>
<!-- PLANO B: o token de administracao, para emergencia. -->
<button type="button" id="usarToken" class="discreto" aria-expanded="false" aria-controls="formEntrar">usar token</button>
<form id="formEntrar" class="plano-b oculto">
<p class="ajuda">
Cole o token de administracao. Ele fica guardado ate voce fechar a aba.
</p>
<label for="token">Token</label>
<input id="token" type="password" autocomplete="off" spellcheck="false" required>
<button type="submit" class="btn">Entrar</button>
<button type="submit" class="btn claro">Entrar com token de administração</button>
</form>
<p id="erroEntrar" class="erro oculto"></p>
</section>
<!-- Logou, mas a conta nao tem `atm_admin`. -->
<section id="negado" class="cartao porta oculto">
<h1>Sem acesso</h1>
<p id="negadoTexto">Esta conta não é administradora da plataforma.</p>
<button type="button" id="sairNegado" class="btn claro">Sair</button>
</section>
<section id="carregando" class="cartao oculto"><p class="ajuda">Juntando os dados do banco, do Asaas e do login&hellip;</p></section>
@@ -3756,22 +3782,41 @@ data:
</main>
<script src="/publico/painel.js?v=2.89"></script>
<script src="/publico/painel.js?v=2.90"></script>
</body>
</html>
painel.js: |
/* O painel do dono.
*
* ── O TOKEN ──────────────────────────────────────────────────────────────────
* ── A ENTRADA: LOGIN DO KEYCLOAK DA PLATAFORMA (07/10/2026) ─────────────────
*
* Fica em `sessionStorage`, e nao em `localStorage`: fechar a aba esquece. Um
* token de administracao que sobrevive ao fechamento fica numa maquina que
* ninguem lembra que o tem.
* Realm `athleticmap` de auth-plataforma, client publico `spa`, Authorization
* Code com PKCE (S256) e `state` contra CSRF — o mesmo desenho do
* `minha-conta.js`, escrito a mao pelo mesmo motivo de la e por mais um: o
* keycloak-js 25 guarda o `code_verifier` do login em `localStorage`
* (`kc-callback-*`), sem opcao de trocar, e esta tela nao poe nada la.
*
* Ele NUNCA e escrito na URL. Query string vai para historico do navegador,
* para log de servidor e para o `Referer` de qualquer link clicado a partir
* daqui — tres lugares onde ninguem procura quando o segredo vaza.
* So entra quem tem o papel de realm `atm_admin`; o 2FA desse papel e o
* Keycloak quem cobra. Ler o papel aqui e so para a tela nao abrir uma porta
* que o servidor vai fechar — quem barra e a captacao, conferindo o JWT.
*
* O token de acesso e o de renovacao vivem SO EM MEMORIA. Em `sessionStorage`
* ficam apenas o `code_verifier`/`state` enquanto o login esta em andamento
* (apagados na volta) e uma marca sem segredo de que esta aba tinha sessao —
* com ela, recarregar a pagina e um ida e volta invisivel ao Keycloak.
*
* ── O PLANO B: O TOKEN DE ADMINISTRACAO ─────────────────────────────────────
*
* Atras do link "usar token", para emergencia (e ate a captacao aceitar o
* login). Fica em `sessionStorage`, e nao em `localStorage`: fechar a aba
* esquece. Um token de administracao que sobrevive ao fechamento fica numa
* maquina que ninguem lembra que o tem.
*
* Nenhum dos dois e escrito na URL nem no console. Query string vai para
* historico do navegador, para log de servidor e para o `Referer` de qualquer
* link clicado a partir daqui — tres lugares onde ninguem procura quando o
* segredo vaza. O `?code=` da volta do login sai da URL antes de virar token.
*
* ── TUDO QUE VEM DO SERVIDOR E ESCAPADO ─────────────────────────────────────
*
@@ -3797,9 +3842,22 @@ data:
var CHAVE = "atm.painel.token";
var INTERVALO_SINO_MS = 60000;
var OIDC = {
// Endereco PUBLICO do realm. Nao e segredo — e o que o navegador visita
// para entrar, e tem de bater com o `connect-src` do bloco
// `location = /painel-do-dono` no nginx.conf.template.
EMISSOR: "https://auth-plataforma.athleticmap.com/realms/athleticmap",
CLIENTE: "spa",
PAPEL: "atm_admin"
};
var CHAVE_LOGIN = "atm.painel.login"; // verifier + state, so durante o login
var CHAVE_SESSAO = "atm.painel.sessao"; // "1": esta aba tinha sessao (sem segredo)
var FOLGA_RENOVAR_S = 30; // renova 30 s antes de o token vencer
var $ = function (id) { return document.getElementById(id); };
var porta = $("porta"), conteudo = $("conteudo"), carregando = $("carregando");
var porta = $("porta"), conteudo = $("conteudo"), carregando = $("carregando"), negado = $("negado");
var formEntrar = $("formEntrar"), campoToken = $("token"), erroEntrar = $("erroEntrar");
var botaoEntrarLogin = $("entrarLogin"), botaoUsarToken = $("usarToken"), botaoSairNegado = $("sairNegado");
var botaoSair = $("sair"), botaoAtualizar = $("atualizar"), apurado = $("apurado");
var corpo = $("linhas"), vazio = $("vazio");
var sinoCaixa = $("sinoCaixa"), sino = $("sino"), sinoContagem = $("sinoContagem"),
@@ -3807,19 +3865,207 @@ data:
sinoVazio = $("sinoVazio"), sinoIndisponivel = $("sinoIndisponivel");
var visao = null;
var tokenAtual = null;
var sinoIntervalo = null;
/* ── token ──────────────────────────────────────────────────────────────── */
/* A credencial da aba, SO EM MEMORIA:
{ tipo: "login", acesso, renovacao } — o JWT do Keycloak (Bearer)
{ tipo: "token", valor } — o plano B (x-atm-admin-token) */
var cred = null;
var renovando = null, relogioRenovar = null;
/* A UNICA saida da pagina passa por aqui (login e logout).
No navegador e `location.assign`. Existe como funcao porque o jsdom torna
`location` imutavel — sem esta costura nenhum teste ve PARA ONDE o login
manda. Mesma costura do minha-conta.js. */
function navegar(url) {
if (typeof window.__atmNavegar === "function") { window.__atmNavegar(url); return; }
location.assign(url);
}
/* ── plano B: o token de administracao ─────────────────────────────────── */
function guardado() { try { return sessionStorage.getItem(CHAVE); } catch (e) { return null; } }
function guardar(t) { try { sessionStorage.setItem(CHAVE, t); } catch (e) { /* aba privada */ } }
function esquecer() { try { sessionStorage.removeItem(CHAVE); } catch (e) { /* idem */ } }
function buscar(token, fresco) {
return fetch(API + (fresco ? "?fresco=true" : ""), { headers: { "x-atm-admin-token": token } })
/* ── login: PKCE ────────────────────────────────────────────────────────── */
function aleatorio(tamanho) {
var bytes = new Uint8Array(tamanho);
crypto.getRandomValues(bytes);
return base64url(bytes);
}
function base64url(bytes) {
var s = "";
for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function desafio(verifier) {
return crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
.then(function (buf) { return base64url(new Uint8Array(buf)); });
}
function retorno() { return location.origin + "/painel-do-dono"; }
function lembrarSessao() { try { sessionStorage.setItem(CHAVE_SESSAO, "1"); } catch (e) { /* idem */ } }
function esquecerSessao() { try { sessionStorage.removeItem(CHAVE_SESSAO); } catch (e) { /* idem */ } }
function tinhaSessao() { try { return sessionStorage.getItem(CHAVE_SESSAO) === "1"; } catch (e) { return false; } }
function irAoLogin() {
var verifier = aleatorio(64), state = aleatorio(24);
try { sessionStorage.setItem(CHAVE_LOGIN, JSON.stringify({ v: verifier, s: state })); } catch (e) { /* idem */ }
return desafio(verifier).then(function (challenge) {
navegar(OIDC.EMISSOR + "/protocol/openid-connect/auth?" + new URLSearchParams({
client_id: OIDC.CLIENTE,
response_type: "code",
scope: "openid",
redirect_uri: retorno(),
state: state,
code_challenge: challenge,
code_challenge_method: "S256"
}).toString());
});
}
function pedirTokens(campos) {
return fetch(OIDC.EMISSOR + "/protocol/openid-connect/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(campos).toString()
}).then(function (r) {
if (!r.ok) throw new Error("token " + r.status);
return r.json();
});
}
function usarTokens(d) {
if (!d || !d.access_token) throw new Error("resposta sem token");
cred = { tipo: "login", acesso: d.access_token,
renovacao: d.refresh_token || (cred && cred.renovacao) || null };
agendarRenovacao(Number(d.expires_in));
}
function pararRenovacao() {
if (relogioRenovar) { clearTimeout(relogioRenovar); relogioRenovar = null; }
}
function agendarRenovacao(segundos) {
pararRenovacao();
if (!isFinite(segundos) || segundos <= 0) return;
var ms = Math.max(5, segundos - FOLGA_RENOVAR_S) * 1000;
relogioRenovar = setTimeout(function () { renovar().catch(sessaoPerdida); }, ms);
}
/* Uma renovacao por vez: o sino, a visao e uma aba podem dar 401 juntos. */
function renovar() {
if (renovando) return renovando;
if (!cred || cred.tipo !== "login" || !cred.renovacao) return Promise.reject(new Error("sem renovacao"));
renovando = pedirTokens({ grant_type: "refresh_token", client_id: OIDC.CLIENTE, refresh_token: cred.renovacao })
.then(function (d) { renovando = null; usarTokens(d); },
function (e) { renovando = null; throw e; });
return renovando;
}
/* So o conteudo do JWT, sem conferir assinatura: e para a TELA decidir o que
mostrar. Quem confere e o servidor. */
function conteudoDoJwt(jwt) {
try {
var p = String(jwt).split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
while (p.length % 4) p += "=";
return JSON.parse(atob(p)) || {};
} catch (e) { return {}; }
}
function ehAdmin(jwt) {
var ra = conteudoDoJwt(jwt).realm_access;
return !!(ra && ra.roles && ra.roles.indexOf(OIDC.PAPEL) >= 0);
}
/* A volta do Keycloak: `?code=&state=` (ou `?error=`). */
function voltarDoLogin(q) {
// O codigo sai da URL ANTES de qualquer outra coisa: e de uso unico, mas
// ficaria no historico e no `Referer` do proximo pedido.
history.replaceState({}, "", location.pathname);
var pendente = null;
try {
pendente = JSON.parse(sessionStorage.getItem(CHAVE_LOGIN) || "null");
sessionStorage.removeItem(CHAVE_LOGIN); // o verifier nao se reusa
} catch (e) { pendente = null; }
if (q.get("error")) {
esquecerSessao();
naPorta("O login não foi concluído.");
return Promise.resolve();
}
// Sem o verifier, ou com `state` de outro login, o codigo NAO vira token:
// e o caso de quem cola a URL de volta noutra aba — ou de quem foi mandado
// para ca com um codigo que nao pediu.
if (!pendente || !pendente.v || !pendente.s || pendente.s !== q.get("state")) {
esquecerSessao();
naPorta("O login expirou ou não começou nesta aba. Entre de novo.");
return Promise.resolve();
}
mostrar(carregando);
return pedirTokens({
grant_type: "authorization_code",
client_id: OIDC.CLIENTE,
code: q.get("code"),
redirect_uri: retorno(),
code_verifier: pendente.v
}).then(function (d) {
usarTokens(d);
return entrarComLogin();
}).catch(function () {
cred = null;
pararRenovacao();
esquecerSessao();
naPorta("Não consegui concluir o login. Tente de novo.");
});
}
/* A renovacao falhou (sessao do Keycloak acabou): de volta a porta. */
function sessaoPerdida() {
if (!cred || cred.tipo !== "login") return;
cred = null;
pararRenovacao();
esquecerSessao();
desligarSino();
fecharFicha();
naPorta("A sessão expirou. Entre de novo.");
}
function sairDoLogin() {
cred = null;
pararRenovacao();
esquecerSessao();
// Encerra tambem a sessao NO KEYCLOAK: limpar so daqui deixaria o proximo
// "Entrar" passar direto, e quem clicou em sair esperava o contrario.
// Sem `id_token_hint` (seria token na URL): o Keycloak pede confirmacao.
navegar(OIDC.EMISSOR + "/protocol/openid-connect/logout?" + new URLSearchParams({
client_id: OIDC.CLIENTE, post_logout_redirect_uri: retorno() }).toString());
}
/* ── todo pedido a API passa por aqui ─────────────────────────────────────
* Poe a credencial no cabecalho (Bearer do login, ou o token do plano B).
* Com o login, um 401 tenta renovar UMA vez e repete o pedido; se a
* renovacao falhar, a sessao acabou e a tela volta para "Entrar". */
function pedir(url, init) {
init = init || {};
var tentar = function () {
var h = {};
Object.keys(init.headers || {}).forEach(function (k) { h[k] = init.headers[k]; });
if (cred && cred.tipo === "login") h.Authorization = "Bearer " + cred.acesso;
else if (cred) h["x-atm-admin-token"] = cred.valor;
return fetch(url, { method: init.method, headers: h, body: init.body });
};
return tentar().then(function (r) {
if (r.status !== 401 || !cred || cred.tipo !== "login") return r;
return renovar().then(tentar, function () { sessaoPerdida(); return r; });
});
}
function buscar(fresco) {
return pedir(API + (fresco ? "?fresco=true" : ""))
.then(function (r) {
if (r.status === 401) { var e = new Error("token"); e.token = true; throw e; }
if (r.status === 403) { var p = new Error("papel"); p.papel = true; throw p; }
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
});
@@ -3832,8 +4078,8 @@ data:
* se o servidor nao responder, o sino so fica "sem dado" — nao pode
* derrubar o painel inteiro por causa de um widget auxiliar.
*/
function buscarAlertasInfra(token) {
return fetch(API_ALERTAS, { headers: { "x-atm-admin-token": token } })
function buscarAlertasInfra() {
return pedir(API_ALERTAS)
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.catch(function () { return null; }); // null = "sem dado", diferente de "zero alertas"
}
@@ -3876,15 +4122,16 @@ data:
});
}
function atualizarSino(token) {
buscarAlertasInfra(token).then(pintarSino);
function atualizarSino() {
if (!cred) return;
buscarAlertasInfra().then(pintarSino);
}
function ligarSino(token) {
function ligarSino() {
sinoCaixa.classList.remove("oculto");
atualizarSino(token);
atualizarSino();
if (sinoIntervalo) clearInterval(sinoIntervalo);
sinoIntervalo = setInterval(function () { atualizarSino(token); }, INTERVALO_SINO_MS);
sinoIntervalo = setInterval(atualizarSino, INTERVALO_SINO_MS);
}
function desligarSino() {
@@ -4490,9 +4737,9 @@ data:
}
function pedirCatalogo(metodo, corpo) {
var h = { "x-atm-admin-token": tokenAtual };
var h = {};
if (corpo) h["Content-Type"] = "application/json";
return fetch(API_CATALOGO, { method: metodo, headers: h, body: corpo ? JSON.stringify(corpo) : undefined })
return pedir(API_CATALOGO, { method: metodo, headers: h, body: corpo ? JSON.stringify(corpo) : undefined })
.then(function (r) {
if (r.ok) return r.json();
return r.json().catch(function () { return {}; }).then(function (d) {
@@ -4502,7 +4749,7 @@ data:
}
function carregarCatalogo() {
if (!tokenAtual) return Promise.resolve();
if (!cred) return Promise.resolve();
return pedirCatalogo("GET").then(function (c) { catalogo = c || {}; pintarCatalogo(); })
.catch(function (e) {
$("catalogoPacotes").innerHTML = '<p class="ajuda">Não consegui carregar: ' + esc(e.message) + "</p>";
@@ -4531,9 +4778,9 @@ data:
/* ── termo de dados ─────────────────────────────────────────────────────── */
function pedirTermo(metodo, caminho, corpo) {
var h = { "x-atm-admin-token": tokenAtual };
var h = {};
if (corpo) h["Content-Type"] = "application/json";
return fetch(API_TERMO + (caminho || ""), { method: metodo, headers: h,
return pedir(API_TERMO + (caminho || ""), { method: metodo, headers: h,
body: corpo ? JSON.stringify(corpo) : undefined })
.then(function (r) {
if (r.ok) return r.json();
@@ -4562,7 +4809,7 @@ data:
}
function carregarTermo() {
if (!tokenAtual) return Promise.resolve();
if (!cred) return Promise.resolve();
return pedirTermo("GET").then(function (lista) {
$("termoVersoes").innerHTML = (lista || []).map(versaoDoTermo).join("")
|| '<p class="ajuda">Nenhuma versão.</p>';
@@ -4630,8 +4877,8 @@ data:
}
function carregarTermosDeUso() {
if (!tokenAtual) return Promise.resolve();
return fetch(API_TERMOS_DE_USO, { headers: { "x-atm-admin-token": tokenAtual } })
if (!cred) return Promise.resolve();
return pedir(API_TERMOS_DE_USO)
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
d = d || {};
@@ -4670,41 +4917,102 @@ data:
}
function mostrar(qual) {
[porta, carregando, conteudo].forEach(function (s) { s.classList.add("oculto"); });
[porta, carregando, conteudo, negado].forEach(function (s) { s.classList.add("oculto"); });
qual.classList.remove("oculto");
var dentro = qual === conteudo;
[botaoSair, botaoAtualizar, apurado].forEach(function (b) { b.classList.toggle("oculto", !dentro); });
}
function carregar(token, fresco) {
return buscar(token, fresco).then(function (v) {
function naPorta(mensagem, comPlanoB) {
erroEntrar.textContent = mensagem || "";
erroEntrar.classList.toggle("oculto", !mensagem);
if (comPlanoB) abrirPlanoB(false);
mostrar(porta);
}
function abrirPlanoB(focar) {
formEntrar.classList.remove("oculto");
botaoUsarToken.setAttribute("aria-expanded", "true");
if (focar) campoToken.focus();
}
function carregar(fresco) {
return buscar(fresco).then(function (v) {
visao = v || {};
tokenAtual = token;
pintar();
mostrar(conteudo);
ligarSino(token);
ligarSino();
});
}
function entrar(token) {
/* Com o JWT do Keycloak em maos. */
function entrarComLogin() {
if (!ehAdmin(cred.acesso)) {
// A conta existe e logou, mas nao administra a plataforma. Nada de
// chamar a API: o servidor recusaria, e a tela diria menos que isto.
negar();
return Promise.resolve();
}
erroEntrar.classList.add("oculto");
mostrar(carregando);
return carregar(token, false)
return carregar(false)
.then(function () { lembrarSessao(); })
.catch(function (e) {
if (!cred) return; // a sessao ja caiu (sessaoPerdida cuidou)
if (e && e.papel) { negar(); return; }
var recusado = e && e.token;
cred = null;
pararRenovacao();
esquecerSessao();
naPorta(recusado
? "O servidor recusou o login. Se a captação ainda não aceita o login do Keycloak, use o token."
: "Nao consegui falar com o servidor. Tente de novo.", recusado);
});
}
function negar() {
cred = null; // o token dessa conta nao serve a esta tela
pararRenovacao();
esquecerSessao();
mostrar(negado);
}
/* Plano B: o token de administracao colado a mao. */
function entrar(token) {
erroEntrar.classList.add("oculto");
pararRenovacao();
esquecerSessao();
cred = { tipo: "token", valor: token };
mostrar(carregando);
return carregar(false)
.then(function () { guardar(token); })
.catch(function (e) {
cred = null;
// O token errado esquece o guardado: manter um token que nao vale so
// faz a proxima abertura falhar em silencio.
if (e && e.token) esquecer();
erroEntrar.textContent = e && e.token
naPorta(e && e.token
? "Token recusado."
: "Nao consegui falar com o servidor. Tente de novo.";
erroEntrar.classList.remove("oculto");
mostrar(porta);
: "Nao consegui falar com o servidor. Tente de novo.", true);
});
}
/* ── eventos ────────────────────────────────────────────────────────────── */
botaoEntrarLogin.addEventListener("click", function () {
botaoEntrarLogin.disabled = true;
irAoLogin().catch(function () {
botaoEntrarLogin.disabled = false;
naPorta("Este navegador não conseguiu preparar o login. Tente de novo.");
});
});
botaoUsarToken.addEventListener("click", function () {
if (formEntrar.classList.contains("oculto")) { abrirPlanoB(true); return; }
formEntrar.classList.add("oculto");
botaoUsarToken.setAttribute("aria-expanded", "false");
});
formEntrar.addEventListener("submit", function (e) {
e.preventDefault();
var t = (campoToken.value || "").trim();
@@ -4712,14 +5020,17 @@ data:
});
botaoSair.addEventListener("click", function () {
if (cred && cred.tipo === "login") { sairDoLogin(); return; }
cred = null;
esquecer();
campoToken.value = "";
location.reload();
});
botaoSairNegado.addEventListener("click", sairDoLogin);
sino.addEventListener("click", function () {
var abrindo = sinoPainel.classList.contains("oculto");
if (abrindo) { abrirSino(); if (tokenAtual) atualizarSino(tokenAtual); } else { fecharSino(); }
if (abrindo) { abrirSino(); atualizarSino(); } else { fecharSino(); }
});
document.addEventListener("click", function (ev) {
if (!sinoPainel.classList.contains("oculto") && !sinoCaixa.contains(ev.target)) fecharSino();
@@ -4729,10 +5040,10 @@ data:
});
botaoAtualizar.addEventListener("click", function () {
if (!tokenAtual) return;
if (!cred) return;
botaoAtualizar.disabled = true;
botaoAtualizar.textContent = "Atualizando…";
carregar(tokenAtual, true).catch(function () { /* mantem o que ja esta na tela */ })
carregar(true).catch(function () { /* mantem o que ja esta na tela */ })
.then(function () { botaoAtualizar.disabled = false; botaoAtualizar.textContent = "Atualizar"; });
});
@@ -4772,7 +5083,7 @@ data:
var API_LICENCA = "/api/plataforma/painel/licencas/";
function acaoDaLicenca(tenant, acao, aviso) {
if (!tokenAtual) return Promise.resolve();
if (!cred) return Promise.resolve();
if (acao === "trocar") {
var digitado = window.prompt("TROCAR a chave de " + tenant + "?\n\n"
+ "A chave atual para de valer e os apps de TODOS da escola (dono, treinadores e pais) saem da conta "
@@ -4785,10 +5096,10 @@ data:
}
}
aviso.textContent = "Enviando…";
return fetch(API_LICENCA + encodeURIComponent(tenant) + "/" + acao, {
method: "POST", headers: { "x-atm-admin-token": tokenAtual, "Content-Type": "application/json" }, body: "{}"
return pedir(API_LICENCA + encodeURIComponent(tenant) + "/" + acao, {
method: "POST", headers: { "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.");
if (r.status === 401) throw new Error("A sessão 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;
@@ -4814,8 +5125,25 @@ data:
$("ficha").addEventListener("click", function (ev) { if (ev.target === $("ficha")) fecharFicha(); });
document.addEventListener("keydown", function (ev) { if (ev.key === "Escape") fecharFicha(); });
var jaTem = guardado();
if (jaTem) entrar(jaTem);
/* ── a abertura ─────────────────────────────────────────────────────────── */
function comecar() {
var q = new URLSearchParams(location.search);
if (q.get("code") || q.get("error")) { voltarDoLogin(q); return; }
var jaTem = guardado();
if (jaTem) { entrar(jaTem); return; }
// Esta aba ja tinha sessao (recarregou): ida e volta ao Keycloak, que
// ainda tem a sessao dele — sem digitar nada. Sem a marca, a porta: um
// redirect automatico faria quem chegou por engano sair sem entender.
if (tinhaSessao()) {
mostrar(carregando);
irAoLogin().catch(function () { esquecerSessao(); naPorta(""); });
return;
}
mostrar(porta);
}
comecar();
})();
planos-modulos.json: |
+386 -58
View File
@@ -3268,6 +3268,13 @@ data:
cursor: pointer; text-decoration: none;
}
.porta .btn { width: 100%; }
/* "usar token": o plano B fica a mao, mas sem competir com o "Entrar". */
.porta .discreto {
display: block; margin: 18px auto 0; padding: 2px 4px; border: 0; background: none;
color: var(--muted); font: inherit; font-size: 12px; text-decoration: underline; cursor: pointer;
}
.porta .discreto:hover { color: var(--navy); }
.porta .plano-b { margin-top: 10px; padding-top: 6px; border-top: 1px solid var(--line); }
.btn:hover { background: var(--orange-600); }
.btn.claro { background: var(--branco); color: var(--navy); border: 1px solid var(--line); }
.btn.claro:hover { border-color: var(--orange); color: var(--orange-600); }
@@ -3501,10 +3508,15 @@ data:
<!--
O PAINEL DO DONO.
Quem barra e o `x-atm-admin-token`, conferido no servidor em tempo constante.
Esta pagina so o guarda em `sessionStorage`: fechar a aba esquece. Nao e
`localStorage` de proposito — um token de administracao que sobrevive ao
fechamento da aba fica numa maquina que ninguem lembra que o tem.
Desde 07/10/2026 a entrada e o LOGIN do Keycloak da plataforma (realm
`athleticmap`, client `spa`, code + PKCE), e so quem tem o papel `atm_admin`
passa — o 2FA desse papel e o Keycloak quem cobra. O token de acesso vive so
em memoria (ver o cabecalho de `painel.js`).
O plano B — o `x-atm-admin-token` colado a mao — continua atras do link
"usar token", para emergencia. Esse fica em `sessionStorage`: fechar a aba
esquece. Nao e `localStorage` de proposito — um token de administracao que
sobrevive ao fechamento da aba fica numa maquina que ninguem lembra que o tem.
NAO HA ESTILO EMBUTIDO: a CSP da plataforma recusa `style=` e `<style>`, e ha
teste guardando isso na landing page. O visual vive em `painel.css`; as
@@ -3514,7 +3526,7 @@ data:
`Cache-Control: immutable` por um ano. Suba o numero a cada publicacao.
-->
<link rel="stylesheet" href="/publico/fontes/fontes.css">
<link rel="stylesheet" href="/publico/painel.css?v=2.86">
<link rel="stylesheet" href="/publico/painel.css?v=2.87">
</head>
<body>
@@ -3540,18 +3552,32 @@ data:
<main class="wrap">
<!-- A porta. Aparece quando nao ha token guardado, e some depois. -->
<!-- A porta. Aparece quando nao ha sessao, e some depois. -->
<section id="porta" class="cartao porta">
<h1>Entrar</h1>
<p class="ajuda">
Cole o token de administracao. Ele fica guardado ate voce fechar a aba.
Entre com a sua conta de administrador da plataforma.
</p>
<form id="formEntrar">
<button type="button" id="entrarLogin" class="btn">Entrar</button>
<p id="erroEntrar" class="erro oculto"></p>
<!-- PLANO B: o token de administracao, para emergencia. -->
<button type="button" id="usarToken" class="discreto" aria-expanded="false" aria-controls="formEntrar">usar token</button>
<form id="formEntrar" class="plano-b oculto">
<p class="ajuda">
Cole o token de administracao. Ele fica guardado ate voce fechar a aba.
</p>
<label for="token">Token</label>
<input id="token" type="password" autocomplete="off" spellcheck="false" required>
<button type="submit" class="btn">Entrar</button>
<button type="submit" class="btn claro">Entrar com token de administração</button>
</form>
<p id="erroEntrar" class="erro oculto"></p>
</section>
<!-- Logou, mas a conta nao tem `atm_admin`. -->
<section id="negado" class="cartao porta oculto">
<h1>Sem acesso</h1>
<p id="negadoTexto">Esta conta não é administradora da plataforma.</p>
<button type="button" id="sairNegado" class="btn claro">Sair</button>
</section>
<section id="carregando" class="cartao oculto"><p class="ajuda">Juntando os dados do banco, do Asaas e do login&hellip;</p></section>
@@ -3756,22 +3782,41 @@ data:
</main>
<script src="/publico/painel.js?v=2.89"></script>
<script src="/publico/painel.js?v=2.90"></script>
</body>
</html>
painel.js: |
/* O painel do dono.
*
* ── O TOKEN ──────────────────────────────────────────────────────────────────
* ── A ENTRADA: LOGIN DO KEYCLOAK DA PLATAFORMA (07/10/2026) ─────────────────
*
* Fica em `sessionStorage`, e nao em `localStorage`: fechar a aba esquece. Um
* token de administracao que sobrevive ao fechamento fica numa maquina que
* ninguem lembra que o tem.
* Realm `athleticmap` de auth-plataforma, client publico `spa`, Authorization
* Code com PKCE (S256) e `state` contra CSRF — o mesmo desenho do
* `minha-conta.js`, escrito a mao pelo mesmo motivo de la e por mais um: o
* keycloak-js 25 guarda o `code_verifier` do login em `localStorage`
* (`kc-callback-*`), sem opcao de trocar, e esta tela nao poe nada la.
*
* Ele NUNCA e escrito na URL. Query string vai para historico do navegador,
* para log de servidor e para o `Referer` de qualquer link clicado a partir
* daqui — tres lugares onde ninguem procura quando o segredo vaza.
* So entra quem tem o papel de realm `atm_admin`; o 2FA desse papel e o
* Keycloak quem cobra. Ler o papel aqui e so para a tela nao abrir uma porta
* que o servidor vai fechar — quem barra e a captacao, conferindo o JWT.
*
* O token de acesso e o de renovacao vivem SO EM MEMORIA. Em `sessionStorage`
* ficam apenas o `code_verifier`/`state` enquanto o login esta em andamento
* (apagados na volta) e uma marca sem segredo de que esta aba tinha sessao —
* com ela, recarregar a pagina e um ida e volta invisivel ao Keycloak.
*
* ── O PLANO B: O TOKEN DE ADMINISTRACAO ─────────────────────────────────────
*
* Atras do link "usar token", para emergencia (e ate a captacao aceitar o
* login). Fica em `sessionStorage`, e nao em `localStorage`: fechar a aba
* esquece. Um token de administracao que sobrevive ao fechamento fica numa
* maquina que ninguem lembra que o tem.
*
* Nenhum dos dois e escrito na URL nem no console. Query string vai para
* historico do navegador, para log de servidor e para o `Referer` de qualquer
* link clicado a partir daqui — tres lugares onde ninguem procura quando o
* segredo vaza. O `?code=` da volta do login sai da URL antes de virar token.
*
* ── TUDO QUE VEM DO SERVIDOR E ESCAPADO ─────────────────────────────────────
*
@@ -3797,9 +3842,22 @@ data:
var CHAVE = "atm.painel.token";
var INTERVALO_SINO_MS = 60000;
var OIDC = {
// Endereco PUBLICO do realm. Nao e segredo — e o que o navegador visita
// para entrar, e tem de bater com o `connect-src` do bloco
// `location = /painel-do-dono` no nginx.conf.template.
EMISSOR: "https://auth-plataforma.athleticmap.com/realms/athleticmap",
CLIENTE: "spa",
PAPEL: "atm_admin"
};
var CHAVE_LOGIN = "atm.painel.login"; // verifier + state, so durante o login
var CHAVE_SESSAO = "atm.painel.sessao"; // "1": esta aba tinha sessao (sem segredo)
var FOLGA_RENOVAR_S = 30; // renova 30 s antes de o token vencer
var $ = function (id) { return document.getElementById(id); };
var porta = $("porta"), conteudo = $("conteudo"), carregando = $("carregando");
var porta = $("porta"), conteudo = $("conteudo"), carregando = $("carregando"), negado = $("negado");
var formEntrar = $("formEntrar"), campoToken = $("token"), erroEntrar = $("erroEntrar");
var botaoEntrarLogin = $("entrarLogin"), botaoUsarToken = $("usarToken"), botaoSairNegado = $("sairNegado");
var botaoSair = $("sair"), botaoAtualizar = $("atualizar"), apurado = $("apurado");
var corpo = $("linhas"), vazio = $("vazio");
var sinoCaixa = $("sinoCaixa"), sino = $("sino"), sinoContagem = $("sinoContagem"),
@@ -3807,19 +3865,207 @@ data:
sinoVazio = $("sinoVazio"), sinoIndisponivel = $("sinoIndisponivel");
var visao = null;
var tokenAtual = null;
var sinoIntervalo = null;
/* ── token ──────────────────────────────────────────────────────────────── */
/* A credencial da aba, SO EM MEMORIA:
{ tipo: "login", acesso, renovacao } — o JWT do Keycloak (Bearer)
{ tipo: "token", valor } — o plano B (x-atm-admin-token) */
var cred = null;
var renovando = null, relogioRenovar = null;
/* A UNICA saida da pagina passa por aqui (login e logout).
No navegador e `location.assign`. Existe como funcao porque o jsdom torna
`location` imutavel — sem esta costura nenhum teste ve PARA ONDE o login
manda. Mesma costura do minha-conta.js. */
function navegar(url) {
if (typeof window.__atmNavegar === "function") { window.__atmNavegar(url); return; }
location.assign(url);
}
/* ── plano B: o token de administracao ─────────────────────────────────── */
function guardado() { try { return sessionStorage.getItem(CHAVE); } catch (e) { return null; } }
function guardar(t) { try { sessionStorage.setItem(CHAVE, t); } catch (e) { /* aba privada */ } }
function esquecer() { try { sessionStorage.removeItem(CHAVE); } catch (e) { /* idem */ } }
function buscar(token, fresco) {
return fetch(API + (fresco ? "?fresco=true" : ""), { headers: { "x-atm-admin-token": token } })
/* ── login: PKCE ────────────────────────────────────────────────────────── */
function aleatorio(tamanho) {
var bytes = new Uint8Array(tamanho);
crypto.getRandomValues(bytes);
return base64url(bytes);
}
function base64url(bytes) {
var s = "";
for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function desafio(verifier) {
return crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
.then(function (buf) { return base64url(new Uint8Array(buf)); });
}
function retorno() { return location.origin + "/painel-do-dono"; }
function lembrarSessao() { try { sessionStorage.setItem(CHAVE_SESSAO, "1"); } catch (e) { /* idem */ } }
function esquecerSessao() { try { sessionStorage.removeItem(CHAVE_SESSAO); } catch (e) { /* idem */ } }
function tinhaSessao() { try { return sessionStorage.getItem(CHAVE_SESSAO) === "1"; } catch (e) { return false; } }
function irAoLogin() {
var verifier = aleatorio(64), state = aleatorio(24);
try { sessionStorage.setItem(CHAVE_LOGIN, JSON.stringify({ v: verifier, s: state })); } catch (e) { /* idem */ }
return desafio(verifier).then(function (challenge) {
navegar(OIDC.EMISSOR + "/protocol/openid-connect/auth?" + new URLSearchParams({
client_id: OIDC.CLIENTE,
response_type: "code",
scope: "openid",
redirect_uri: retorno(),
state: state,
code_challenge: challenge,
code_challenge_method: "S256"
}).toString());
});
}
function pedirTokens(campos) {
return fetch(OIDC.EMISSOR + "/protocol/openid-connect/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(campos).toString()
}).then(function (r) {
if (!r.ok) throw new Error("token " + r.status);
return r.json();
});
}
function usarTokens(d) {
if (!d || !d.access_token) throw new Error("resposta sem token");
cred = { tipo: "login", acesso: d.access_token,
renovacao: d.refresh_token || (cred && cred.renovacao) || null };
agendarRenovacao(Number(d.expires_in));
}
function pararRenovacao() {
if (relogioRenovar) { clearTimeout(relogioRenovar); relogioRenovar = null; }
}
function agendarRenovacao(segundos) {
pararRenovacao();
if (!isFinite(segundos) || segundos <= 0) return;
var ms = Math.max(5, segundos - FOLGA_RENOVAR_S) * 1000;
relogioRenovar = setTimeout(function () { renovar().catch(sessaoPerdida); }, ms);
}
/* Uma renovacao por vez: o sino, a visao e uma aba podem dar 401 juntos. */
function renovar() {
if (renovando) return renovando;
if (!cred || cred.tipo !== "login" || !cred.renovacao) return Promise.reject(new Error("sem renovacao"));
renovando = pedirTokens({ grant_type: "refresh_token", client_id: OIDC.CLIENTE, refresh_token: cred.renovacao })
.then(function (d) { renovando = null; usarTokens(d); },
function (e) { renovando = null; throw e; });
return renovando;
}
/* So o conteudo do JWT, sem conferir assinatura: e para a TELA decidir o que
mostrar. Quem confere e o servidor. */
function conteudoDoJwt(jwt) {
try {
var p = String(jwt).split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
while (p.length % 4) p += "=";
return JSON.parse(atob(p)) || {};
} catch (e) { return {}; }
}
function ehAdmin(jwt) {
var ra = conteudoDoJwt(jwt).realm_access;
return !!(ra && ra.roles && ra.roles.indexOf(OIDC.PAPEL) >= 0);
}
/* A volta do Keycloak: `?code=&state=` (ou `?error=`). */
function voltarDoLogin(q) {
// O codigo sai da URL ANTES de qualquer outra coisa: e de uso unico, mas
// ficaria no historico e no `Referer` do proximo pedido.
history.replaceState({}, "", location.pathname);
var pendente = null;
try {
pendente = JSON.parse(sessionStorage.getItem(CHAVE_LOGIN) || "null");
sessionStorage.removeItem(CHAVE_LOGIN); // o verifier nao se reusa
} catch (e) { pendente = null; }
if (q.get("error")) {
esquecerSessao();
naPorta("O login não foi concluído.");
return Promise.resolve();
}
// Sem o verifier, ou com `state` de outro login, o codigo NAO vira token:
// e o caso de quem cola a URL de volta noutra aba — ou de quem foi mandado
// para ca com um codigo que nao pediu.
if (!pendente || !pendente.v || !pendente.s || pendente.s !== q.get("state")) {
esquecerSessao();
naPorta("O login expirou ou não começou nesta aba. Entre de novo.");
return Promise.resolve();
}
mostrar(carregando);
return pedirTokens({
grant_type: "authorization_code",
client_id: OIDC.CLIENTE,
code: q.get("code"),
redirect_uri: retorno(),
code_verifier: pendente.v
}).then(function (d) {
usarTokens(d);
return entrarComLogin();
}).catch(function () {
cred = null;
pararRenovacao();
esquecerSessao();
naPorta("Não consegui concluir o login. Tente de novo.");
});
}
/* A renovacao falhou (sessao do Keycloak acabou): de volta a porta. */
function sessaoPerdida() {
if (!cred || cred.tipo !== "login") return;
cred = null;
pararRenovacao();
esquecerSessao();
desligarSino();
fecharFicha();
naPorta("A sessão expirou. Entre de novo.");
}
function sairDoLogin() {
cred = null;
pararRenovacao();
esquecerSessao();
// Encerra tambem a sessao NO KEYCLOAK: limpar so daqui deixaria o proximo
// "Entrar" passar direto, e quem clicou em sair esperava o contrario.
// Sem `id_token_hint` (seria token na URL): o Keycloak pede confirmacao.
navegar(OIDC.EMISSOR + "/protocol/openid-connect/logout?" + new URLSearchParams({
client_id: OIDC.CLIENTE, post_logout_redirect_uri: retorno() }).toString());
}
/* ── todo pedido a API passa por aqui ─────────────────────────────────────
* Poe a credencial no cabecalho (Bearer do login, ou o token do plano B).
* Com o login, um 401 tenta renovar UMA vez e repete o pedido; se a
* renovacao falhar, a sessao acabou e a tela volta para "Entrar". */
function pedir(url, init) {
init = init || {};
var tentar = function () {
var h = {};
Object.keys(init.headers || {}).forEach(function (k) { h[k] = init.headers[k]; });
if (cred && cred.tipo === "login") h.Authorization = "Bearer " + cred.acesso;
else if (cred) h["x-atm-admin-token"] = cred.valor;
return fetch(url, { method: init.method, headers: h, body: init.body });
};
return tentar().then(function (r) {
if (r.status !== 401 || !cred || cred.tipo !== "login") return r;
return renovar().then(tentar, function () { sessaoPerdida(); return r; });
});
}
function buscar(fresco) {
return pedir(API + (fresco ? "?fresco=true" : ""))
.then(function (r) {
if (r.status === 401) { var e = new Error("token"); e.token = true; throw e; }
if (r.status === 403) { var p = new Error("papel"); p.papel = true; throw p; }
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
});
@@ -3832,8 +4078,8 @@ data:
* se o servidor nao responder, o sino so fica "sem dado" — nao pode
* derrubar o painel inteiro por causa de um widget auxiliar.
*/
function buscarAlertasInfra(token) {
return fetch(API_ALERTAS, { headers: { "x-atm-admin-token": token } })
function buscarAlertasInfra() {
return pedir(API_ALERTAS)
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.catch(function () { return null; }); // null = "sem dado", diferente de "zero alertas"
}
@@ -3876,15 +4122,16 @@ data:
});
}
function atualizarSino(token) {
buscarAlertasInfra(token).then(pintarSino);
function atualizarSino() {
if (!cred) return;
buscarAlertasInfra().then(pintarSino);
}
function ligarSino(token) {
function ligarSino() {
sinoCaixa.classList.remove("oculto");
atualizarSino(token);
atualizarSino();
if (sinoIntervalo) clearInterval(sinoIntervalo);
sinoIntervalo = setInterval(function () { atualizarSino(token); }, INTERVALO_SINO_MS);
sinoIntervalo = setInterval(atualizarSino, INTERVALO_SINO_MS);
}
function desligarSino() {
@@ -4490,9 +4737,9 @@ data:
}
function pedirCatalogo(metodo, corpo) {
var h = { "x-atm-admin-token": tokenAtual };
var h = {};
if (corpo) h["Content-Type"] = "application/json";
return fetch(API_CATALOGO, { method: metodo, headers: h, body: corpo ? JSON.stringify(corpo) : undefined })
return pedir(API_CATALOGO, { method: metodo, headers: h, body: corpo ? JSON.stringify(corpo) : undefined })
.then(function (r) {
if (r.ok) return r.json();
return r.json().catch(function () { return {}; }).then(function (d) {
@@ -4502,7 +4749,7 @@ data:
}
function carregarCatalogo() {
if (!tokenAtual) return Promise.resolve();
if (!cred) return Promise.resolve();
return pedirCatalogo("GET").then(function (c) { catalogo = c || {}; pintarCatalogo(); })
.catch(function (e) {
$("catalogoPacotes").innerHTML = '<p class="ajuda">Não consegui carregar: ' + esc(e.message) + "</p>";
@@ -4531,9 +4778,9 @@ data:
/* ── termo de dados ─────────────────────────────────────────────────────── */
function pedirTermo(metodo, caminho, corpo) {
var h = { "x-atm-admin-token": tokenAtual };
var h = {};
if (corpo) h["Content-Type"] = "application/json";
return fetch(API_TERMO + (caminho || ""), { method: metodo, headers: h,
return pedir(API_TERMO + (caminho || ""), { method: metodo, headers: h,
body: corpo ? JSON.stringify(corpo) : undefined })
.then(function (r) {
if (r.ok) return r.json();
@@ -4562,7 +4809,7 @@ data:
}
function carregarTermo() {
if (!tokenAtual) return Promise.resolve();
if (!cred) return Promise.resolve();
return pedirTermo("GET").then(function (lista) {
$("termoVersoes").innerHTML = (lista || []).map(versaoDoTermo).join("")
|| '<p class="ajuda">Nenhuma versão.</p>';
@@ -4630,8 +4877,8 @@ data:
}
function carregarTermosDeUso() {
if (!tokenAtual) return Promise.resolve();
return fetch(API_TERMOS_DE_USO, { headers: { "x-atm-admin-token": tokenAtual } })
if (!cred) return Promise.resolve();
return pedir(API_TERMOS_DE_USO)
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
d = d || {};
@@ -4670,41 +4917,102 @@ data:
}
function mostrar(qual) {
[porta, carregando, conteudo].forEach(function (s) { s.classList.add("oculto"); });
[porta, carregando, conteudo, negado].forEach(function (s) { s.classList.add("oculto"); });
qual.classList.remove("oculto");
var dentro = qual === conteudo;
[botaoSair, botaoAtualizar, apurado].forEach(function (b) { b.classList.toggle("oculto", !dentro); });
}
function carregar(token, fresco) {
return buscar(token, fresco).then(function (v) {
function naPorta(mensagem, comPlanoB) {
erroEntrar.textContent = mensagem || "";
erroEntrar.classList.toggle("oculto", !mensagem);
if (comPlanoB) abrirPlanoB(false);
mostrar(porta);
}
function abrirPlanoB(focar) {
formEntrar.classList.remove("oculto");
botaoUsarToken.setAttribute("aria-expanded", "true");
if (focar) campoToken.focus();
}
function carregar(fresco) {
return buscar(fresco).then(function (v) {
visao = v || {};
tokenAtual = token;
pintar();
mostrar(conteudo);
ligarSino(token);
ligarSino();
});
}
function entrar(token) {
/* Com o JWT do Keycloak em maos. */
function entrarComLogin() {
if (!ehAdmin(cred.acesso)) {
// A conta existe e logou, mas nao administra a plataforma. Nada de
// chamar a API: o servidor recusaria, e a tela diria menos que isto.
negar();
return Promise.resolve();
}
erroEntrar.classList.add("oculto");
mostrar(carregando);
return carregar(token, false)
return carregar(false)
.then(function () { lembrarSessao(); })
.catch(function (e) {
if (!cred) return; // a sessao ja caiu (sessaoPerdida cuidou)
if (e && e.papel) { negar(); return; }
var recusado = e && e.token;
cred = null;
pararRenovacao();
esquecerSessao();
naPorta(recusado
? "O servidor recusou o login. Se a captação ainda não aceita o login do Keycloak, use o token."
: "Nao consegui falar com o servidor. Tente de novo.", recusado);
});
}
function negar() {
cred = null; // o token dessa conta nao serve a esta tela
pararRenovacao();
esquecerSessao();
mostrar(negado);
}
/* Plano B: o token de administracao colado a mao. */
function entrar(token) {
erroEntrar.classList.add("oculto");
pararRenovacao();
esquecerSessao();
cred = { tipo: "token", valor: token };
mostrar(carregando);
return carregar(false)
.then(function () { guardar(token); })
.catch(function (e) {
cred = null;
// O token errado esquece o guardado: manter um token que nao vale so
// faz a proxima abertura falhar em silencio.
if (e && e.token) esquecer();
erroEntrar.textContent = e && e.token
naPorta(e && e.token
? "Token recusado."
: "Nao consegui falar com o servidor. Tente de novo.";
erroEntrar.classList.remove("oculto");
mostrar(porta);
: "Nao consegui falar com o servidor. Tente de novo.", true);
});
}
/* ── eventos ────────────────────────────────────────────────────────────── */
botaoEntrarLogin.addEventListener("click", function () {
botaoEntrarLogin.disabled = true;
irAoLogin().catch(function () {
botaoEntrarLogin.disabled = false;
naPorta("Este navegador não conseguiu preparar o login. Tente de novo.");
});
});
botaoUsarToken.addEventListener("click", function () {
if (formEntrar.classList.contains("oculto")) { abrirPlanoB(true); return; }
formEntrar.classList.add("oculto");
botaoUsarToken.setAttribute("aria-expanded", "false");
});
formEntrar.addEventListener("submit", function (e) {
e.preventDefault();
var t = (campoToken.value || "").trim();
@@ -4712,14 +5020,17 @@ data:
});
botaoSair.addEventListener("click", function () {
if (cred && cred.tipo === "login") { sairDoLogin(); return; }
cred = null;
esquecer();
campoToken.value = "";
location.reload();
});
botaoSairNegado.addEventListener("click", sairDoLogin);
sino.addEventListener("click", function () {
var abrindo = sinoPainel.classList.contains("oculto");
if (abrindo) { abrirSino(); if (tokenAtual) atualizarSino(tokenAtual); } else { fecharSino(); }
if (abrindo) { abrirSino(); atualizarSino(); } else { fecharSino(); }
});
document.addEventListener("click", function (ev) {
if (!sinoPainel.classList.contains("oculto") && !sinoCaixa.contains(ev.target)) fecharSino();
@@ -4729,10 +5040,10 @@ data:
});
botaoAtualizar.addEventListener("click", function () {
if (!tokenAtual) return;
if (!cred) return;
botaoAtualizar.disabled = true;
botaoAtualizar.textContent = "Atualizando…";
carregar(tokenAtual, true).catch(function () { /* mantem o que ja esta na tela */ })
carregar(true).catch(function () { /* mantem o que ja esta na tela */ })
.then(function () { botaoAtualizar.disabled = false; botaoAtualizar.textContent = "Atualizar"; });
});
@@ -4772,7 +5083,7 @@ data:
var API_LICENCA = "/api/plataforma/painel/licencas/";
function acaoDaLicenca(tenant, acao, aviso) {
if (!tokenAtual) return Promise.resolve();
if (!cred) return Promise.resolve();
if (acao === "trocar") {
var digitado = window.prompt("TROCAR a chave de " + tenant + "?\n\n"
+ "A chave atual para de valer e os apps de TODOS da escola (dono, treinadores e pais) saem da conta "
@@ -4785,10 +5096,10 @@ data:
}
}
aviso.textContent = "Enviando…";
return fetch(API_LICENCA + encodeURIComponent(tenant) + "/" + acao, {
method: "POST", headers: { "x-atm-admin-token": tokenAtual, "Content-Type": "application/json" }, body: "{}"
return pedir(API_LICENCA + encodeURIComponent(tenant) + "/" + acao, {
method: "POST", headers: { "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.");
if (r.status === 401) throw new Error("A sessão 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;
@@ -4814,8 +5125,25 @@ data:
$("ficha").addEventListener("click", function (ev) { if (ev.target === $("ficha")) fecharFicha(); });
document.addEventListener("keydown", function (ev) { if (ev.key === "Escape") fecharFicha(); });
var jaTem = guardado();
if (jaTem) entrar(jaTem);
/* ── a abertura ─────────────────────────────────────────────────────────── */
function comecar() {
var q = new URLSearchParams(location.search);
if (q.get("code") || q.get("error")) { voltarDoLogin(q); return; }
var jaTem = guardado();
if (jaTem) { entrar(jaTem); return; }
// Esta aba ja tinha sessao (recarregou): ida e volta ao Keycloak, que
// ainda tem a sessao dele — sem digitar nada. Sem a marca, a porta: um
// redirect automatico faria quem chegou por engano sair sem entender.
if (tinhaSessao()) {
mostrar(carregando);
irAoLogin().catch(function () { esquecerSessao(); naPorta(""); });
return;
}
mostrar(porta);
}
comecar();
})();
planos-modulos.json: |