From 177b60f90975df7c499b5b46455af66cee856233 Mon Sep 17 00:00:00 2001 From: deploy Date: Wed, 7 Oct 2026 20:42:09 +0000 Subject: [PATCH] chore(tenants): conteudo de publico/ atualizado --- moldes/consolidado/96-publico-configmap.yaml | 444 ++++++++++++++++--- tenants/plataforma/96-publico-configmap.yaml | 444 ++++++++++++++++--- 2 files changed, 772 insertions(+), 116 deletions(-) diff --git a/moldes/consolidado/96-publico-configmap.yaml b/moldes/consolidado/96-publico-configmap.yaml index 629e747..248ea63 100644 --- a/moldes/consolidado/96-publico-configmap.yaml +++ b/moldes/consolidado/96-publico-configmap.yaml @@ -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: - + @@ -3540,18 +3552,32 @@ data:
- +

Entrar

- Cole o token de administracao. Ele fica guardado ate voce fechar a aba. + Entre com a sua conta de administrador da plataforma.

-
+ +

+ + + + +

+ Cole o token de administracao. Ele fica guardado ate voce fechar a aba. +

- +
-

+
+ + +
+

Sem acesso

+

Esta conta não é administradora da plataforma.

+

Juntando os dados do banco, do Asaas e do login…

@@ -3756,22 +3782,41 @@ data:
- + 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 = '

Não consegui carregar: ' + esc(e.message) + "

"; @@ -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("") || '

Nenhuma versão.

'; @@ -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: | diff --git a/tenants/plataforma/96-publico-configmap.yaml b/tenants/plataforma/96-publico-configmap.yaml index f6ab5e0..38e8e3f 100644 --- a/tenants/plataforma/96-publico-configmap.yaml +++ b/tenants/plataforma/96-publico-configmap.yaml @@ -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: - + @@ -3540,18 +3552,32 @@ data:
- +

Entrar

- Cole o token de administracao. Ele fica guardado ate voce fechar a aba. + Entre com a sua conta de administrador da plataforma.

-
+ +

+ + + + +

+ Cole o token de administracao. Ele fica guardado ate voce fechar a aba. +

- +
-

+
+ + +
+

Sem acesso

+

Esta conta não é administradora da plataforma.

+

Juntando os dados do banco, do Asaas e do login…

@@ -3756,22 +3782,41 @@ data:
- + 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 = '

Não consegui carregar: ' + esc(e.message) + "

"; @@ -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("") || '

Nenhuma versão.

'; @@ -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: |