chore(tenants): conteudo de publico/ atualizado
This commit is contained in:
@@ -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…</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: |
|
||||
|
||||
@@ -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…</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: |
|
||||
|
||||
Reference in New Issue
Block a user