feat(atm-escolinha): tela de login Athletic Map

This commit is contained in:
deploy
2026-10-08 13:38:22 +00:00
parent 123ce74508
commit 02c9a18a7d
+739 -3
View File
@@ -1,5 +1,5 @@
# GERADO por scripts/tema_login_escolinha.py — nao edite na mao.
# Fonte: keycloak-theme/escolinha/{login,login-update-password,estilo,template}.ftl
# Fonte: keycloak-theme/escolinha/*.ftl (login.ftl, login-update-password.ftl, estilo.ftl, template.ftl, error.ftl, info.ftl, logout-confirm.ftl, login-page-expired.ftl, login-reset-password.ftl, login-config-totp.ftl, login-otp.ftl, webauthn-register.ftl, webauthn-authenticate.ftl, select-authenticator.ftl)
apiVersion: v1
kind: ConfigMap
metadata:
@@ -487,7 +487,9 @@ data:
.botao{
display:flex;align-items:center;justify-content:center;gap:10px;
width:100%;height:56px;border:0;border-radius:12px;
font-size:16px;font-weight:700;cursor:pointer;
/* `font-family:inherit`: <button> nao herda a fonte da pagina, e o
"Tente outra forma" saia em Arial (2026-10-08). */
font-family:inherit;font-size:16px;font-weight:700;cursor:pointer;
transition:background .15s ease, border-color .15s ease;
}
.botao:focus-visible{outline:3px solid rgba(245,130,32,.45);outline-offset:2px}
@@ -539,6 +541,81 @@ data:
.aviso-success{background:#EAF6EC;color:#2F7D43;border:1px solid #C6E6CD}
.aviso-warning,.aviso-info{background:#FFF5E8;color:#9A5A12;border:1px solid #F6DCB4}
/* ---------- Paginas escritas para o template.ftl (2026-10-08) ----------
error, info, logout-confirm, esqueci a senha, pagina expirada, 2FA
(configurar e digitar), passkey (cadastrar e entrar) e escolher metodo.
Elas desenham com as classes do login (.campo, .caixa, .botao), e o que
segue e so o que o login nao tinha. */
.selo{
display:flex;align-items:center;justify-content:center;
width:52px;height:52px;border-radius:50%;margin:0 0 18px;
background:#FFF1E3;color:var(--orange);
}
.selo svg{width:26px;height:26px}
.selo.erro{background:#FDECE9;color:#B23B28}
.conteudo .lembrar{display:flex;margin:0;font-size:14px;font-weight:400;color:var(--muted)}
.selo.ok{background:#EAF6EC;color:#2F7D43}
.conteudo .sub{margin:0 0 14px}
.conteudo form{margin-top:10px}
.conteudo a.botao{color:#fff;text-decoration:none}
.conteudo a.botao:hover{color:#fff}
.conteudo a.botao.secundario,.conteudo a.botao.secundario:hover{color:var(--muted)}
.conteudo a.botao.secundario:hover{color:var(--navy)}
.conteudo .caixa input{padding:0 50px;height:54px;border-radius:12px;border-width:1.5px}
.caixa input.codigo{letter-spacing:.32em;font-size:20px;font-weight:700;padding-left:50px}
.erro-campo{display:block;margin-top:2px;font-size:13px;color:#B23B28}
.voltar{display:block;text-align:center;margin-top:18px;font-size:14px;color:var(--blue)!important;text-decoration:none!important}
.voltar:hover{text-decoration:underline!important}
.detalhe{font-size:13px;color:var(--muted)}
.detalhe code{font-family:ui-monospace,Consolas,monospace;font-size:12.5px;color:var(--navy);
background:#fff;border:1px solid var(--line);border-radius:6px;padding:1px 6px;word-break:break-all}
.conteudo .lista-acoes{margin:0 0 18px;padding:0;list-style:none}
.lista-acoes li{display:flex;align-items:center;gap:10px;margin:0 0 8px;padding:11px 14px;
background:#fff;border:1px solid var(--line);border-radius:11px;font-weight:600}
.lista-acoes li::before{content:'';flex:0 0 8px;height:8px;border-radius:50%;background:var(--orange)}
/* Configurar o 2FA: os tres passos numerados, o QR e a chave manual. */
.conteudo .passos{counter-reset:passo;margin:0 0 6px;padding:0;list-style:none}
.passos>li{counter-increment:passo;position:relative;padding:0 0 16px 40px;margin:0}
.passos>li::before{
content:counter(passo);position:absolute;left:0;top:-2px;
width:26px;height:26px;border-radius:50%;background:var(--navy);color:#fff;
font-size:13px;font-weight:700;display:flex;align-items:center;justify-content:center;
}
.passos p{margin:0 0 8px}
.conteudo .apps{display:flex;flex-wrap:wrap;gap:6px;margin:0;padding:0;list-style:none}
.apps li{margin:0;padding:4px 10px;font-size:13px;background:#fff;border:1px solid var(--line);border-radius:999px}
.qr{display:inline-block;padding:10px;background:#fff;border:1px solid var(--line);border-radius:14px;line-height:0}
.qr img{width:176px;height:176px;image-rendering:pixelated}
.chave-manual{display:block;padding:12px 14px;background:#fff;border:1px dashed #CFCDC8;border-radius:11px;
font-family:ui-monospace,Consolas,monospace;font-size:15px;font-weight:600;letter-spacing:.06em;word-break:break-all}
.conteudo .config-manual{margin:8px 0 0;padding:0;list-style:none;font-size:13.5px;color:var(--muted)}
.config-manual li{margin:0 0 2px}
.passos .troca{font-size:14px}
/* Escolher o metodo (select-authenticator), as credenciais OTP e as passkeys:
cartoes brancos clicaveis. `.conteudo .cartao` vence o `.conteudo button`
laranja de cima, que e o botao de acao. */
.cartoes{display:flex;flex-direction:column;gap:10px;margin:0}
.conteudo .cartao{
display:flex;align-items:center;gap:14px;width:100%;margin:0;padding:14px 16px;
background:#fff;color:var(--navy);border:1.5px solid var(--line);border-radius:12px;
font:inherit;font-size:15px;font-weight:400;text-align:left;cursor:pointer;
transition:border-color .15s ease, box-shadow .15s ease;
}
.conteudo button.cartao:hover,.conteudo label.cartao:hover{background:#fff;border-color:var(--orange)}
.conteudo .cartao:focus-visible{outline:3px solid rgba(245,130,32,.45);outline-offset:2px}
.cartao .ico{flex:0 0 40px;height:40px;border-radius:10px;background:#FFF1E3;color:var(--orange);
display:flex;align-items:center;justify-content:center}
.cartao .ico svg{width:22px;height:22px}
.cartao .txt{flex:1;min-width:0}
.cartao .txt b{display:block;font-size:15px;font-weight:700}
.cartao .txt span{display:block;font-size:13px;color:var(--muted);margin-top:2px}
.cartao .seta{color:#B6B4AF;display:flex}
.credenciais input{position:absolute;opacity:0;pointer-events:none}
.credenciais input:checked+label.cartao{border-color:var(--orange);box-shadow:0 0 0 3px rgba(245,130,32,.16)}
.conteudo .cartao.estatico{cursor:default}
/* ---------- Telas estreitas ---------- */
@media (max-width:980px){
.stage{grid-template-columns:1fr}
@@ -611,6 +688,15 @@ data:
info o rodape informativo, so quando displayInfo
socialProviders os provedores externos, quando houver
E DUAS SECOES QUE SAO DESTE TEMA (2026-10-08), usadas so pelas paginas
escritas aqui (error, info, logout-confirm, login-otp...):
selo o icone redondo acima do titulo
sub a linha cinza abaixo do titulo, como a do login
Uma pagina do tema BASE nunca as preenche (as dela sao if/elseif sobre os
quatro nomes acima), entao para ela as duas saem vazias e nao desenham nada.
── O QUE ELA DELIBERADAMENTE NAO FAZ ────────────────────────────────────────
Nao carrega `properties.styles` nem `properties.scripts` do tema base: o
@@ -625,6 +711,14 @@ data:
Mesmo include do `login.ftl`: elas pesam ~400 KB em data URI e existem uma
vez no ConfigMap.
-->
<#--
As chaves pt-BR de link do Keycloak vem com seta de texto: "« Voltar para o
aplicativo", "» Clique aqui para continuar". Num botao a seta sobra. Escrita
com escape (\xAB, \xBB) para nao depender da codificacao de quem le o arquivo.
-->
<#function semSeta texto>
<#return texto?replace("\xAB", "")?replace("\xBB", "")?trim>
</#function>
<#macro registrationLayout bodyClass="" displayInfo=false displayMessage=true displayRequiredFields=false>
<!DOCTYPE html>
<html lang="pt-BR">
@@ -662,8 +756,20 @@ data:
<div class="form">
<span class="marca" role="img" aria-label="Athletic Map"></span>
<#assign selo><#nested "selo"></#assign>
<#if selo?markup_string?trim?has_content>
<#-- A pagina traz o proprio `<span class="selo [erro|ok]">`: a cor
depende do que ela diz. -->
${selo}
</#if>
<h1><#nested "header"></h1>
<#assign sub><#nested "sub"></#assign>
<#if sub?markup_string?trim?has_content>
<p class="sub">${sub}</p>
</#if>
<#-- A mesma regra do tema base: uma acao iniciada pelo proprio app nao
mostra o aviso de "voce precisa completar isto", porque quem a
iniciou foi o usuario, agora. -->
@@ -681,7 +787,9 @@ data:
<#if auth?has_content && auth.showTryAnotherWayLink()>
<form id="kc-select-try-another-way-form" action="${url.loginAction}" method="post">
<input type="hidden" name="tryAnotherWay" value="on"/>
<button class="entrar secundario" type="submit">${msg("doTryAnotherWay")}</button>
<#-- `botao`: sem ele o "Tente outra forma" saia sem altura nem
alinhamento — so a borda (achado em 2026-10-08). -->
<button class="botao entrar secundario" type="submit">${msg("doTryAnotherWay")}</button>
</form>
</#if>
@@ -702,3 +810,631 @@ data:
</html>
</#macro>
error.ftl: |-
<#--
A tela de ERRO do Keycloak ("Sentimos muito..."): redirect_uri invalido,
client inexistente, link de acao invalido ou ja usado, sessao perdida.
Ate 2026-10-08 era a `error.ftl` do tema BASE dentro do `template.ftl` deste:
ja tinha a marca, mas o titulo "Sentimos muito..." e o texto tecnico solto
("Parametro invalido: redirect_uri") sem dizer o que a pessoa faz agora. Quem
chega aqui quase sempre so precisa voltar e tentar de novo.
Textos: as chaves pt-BR do Keycloak quando existem (`errorTitle`,
`backToApplication`). O `traceIdSupportMessage` NAO tem traducao pt-BR no
26.7.5 — sairia em ingles —, entao a frase do codigo de suporte e fixa aqui.
O id `kc-error-message` e o mesmo do base (o validador procura por ele).
-->
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=false; section>
<#if section = "selo">
<span class="selo erro"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5"/><path d="M12 16.5h.01"/></svg></span>
<#elseif section = "header">
Algo deu <span class="hl">errado</span>
<#elseif section = "sub">
${kcSanitize(msg("errorTitle"))?no_esc} N&atilde;o foi poss&iacute;vel concluir o seu pedido.
<#elseif section = "form">
<div id="kc-error-message">
<#if message?? && message.summary?has_content>
<div class="aviso aviso-error">${kcSanitize(message.summary)?no_esc}</div>
</#if>
<p>Volte para o aplicativo e tente de novo. Se continuar acontecendo, fale com o suporte.</p>
<#if traceId??>
<p id="traceId" class="detalhe">Se falar com o suporte, informe este c&oacute;digo: <code>${traceId}</code></p>
</#if>
<#if !skipLink??>
<#if client?? && client.baseUrl?has_content>
<a id="backToApplication" class="botao entrar" href="${client.baseUrl}">${layout.semSeta(msg("backToApplication"))}</a>
</#if>
</#if>
</div>
</#if>
</@layout.registrationLayout>
info.ftl: |-
<#--
As telas de INFORMACAO do Keycloak: "execute estas acoes" (o link do e-mail de
convite/troca de senha), "sua conta foi atualizada", "voce saiu da sessao",
e-mail verificado, acao expirada com sessao, etc.
Ate 2026-10-08 era a `info.ftl` do tema BASE na moldura deste. Dois defeitos
que motivaram esta: o titulo "Account updated" saia em INGLES (a chave
`accountUpdatedTitle` nao tem traducao pt-BR no 26.7.5), e o titulo repetia
palavra por palavra a frase de baixo.
TITULO: `messageHeader` quando o Keycloak manda um (traduzido aqui quando o
pt-BR de fabrica nao tem); senao, a propria mensagem — e entao ela NAO se
repete embaixo.
Os ids `kc-info-message` e os links sao os do base; o validador procura o id.
-->
<#import "template.ftl" as layout>
<#-- Chaves de titulo que o pt-BR do Keycloak 26.7.5 nao traduz. -->
<#assign titulosPtBr = {"accountUpdatedTitle": "Conta atualizada"}>
<#-- Nomes de acao obrigatoria que em pt-BR de fabrica sao tecnicos demais. -->
<#assign acoesPtBr = {
"webauthn-register-passwordless": "Cadastrar uma chave de seguran\xE7a (passkey)",
"webauthn-register": "Cadastrar uma chave de seguran\xE7a para o segundo fator"}>
<#assign sucesso = ((message.type)!'') == 'success' || (messageHeader!'') == 'accountUpdatedTitle'>
<@layout.registrationLayout displayMessage=false; section>
<#if section = "selo">
<#if requiredActions??>
<span class="selo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="4" width="14" height="17" rx="2"/><path d="M9 4.5V3h6v1.5"/><path d="m9 12 2 2 4-4"/></svg></span>
<#elseif sucesso>
<span class="selo ok"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="m8 12.5 2.7 2.7L16 10"/></svg></span>
<#else>
<span class="selo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><path d="M12 7.5h.01"/></svg></span>
</#if>
<#elseif section = "header">
<#if messageHeader??>
${kcSanitize(titulosPtBr[messageHeader]!msg("${messageHeader}"))?no_esc}
<#elseif requiredActions??>
Antes de <span class="hl">continuar</span>
<#else>
${(message.summary)!''}
</#if>
<#elseif section = "sub">
<#if messageHeader?? || requiredActions??>${(message.summary)!''}</#if>
<#elseif section = "form">
<div id="kc-info-message">
<#if requiredActions??>
<ul class="lista-acoes">
<#list requiredActions as acao>
<li>${kcSanitize(acoesPtBr[acao]!msg("requiredAction.${acao}"))?no_esc}</li>
</#list>
</ul>
</#if>
<#if !skipLink??>
<#if pageRedirectUri?has_content>
<a class="botao entrar" href="${pageRedirectUri}">${layout.semSeta(msg("backToApplication"))}</a>
<#elseif actionUri?has_content>
<a class="botao entrar" href="${actionUri}">${msg("doContinue")}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg></a>
<#elseif (client.baseUrl)?has_content>
<a class="botao entrar" href="${client.baseUrl}">${layout.semSeta(msg("backToApplication"))}</a>
</#if>
</#if>
</div>
</#if>
</@layout.registrationLayout>
logout-confirm.ftl: |-
<#--
CONFIRMAR A SAIDA — o que o Keycloak mostra quando o logout chega sem
`id_token_hint` (link antigo, app que perdeu o token, alguem digitando a URL).
O "Sair" do Painel do dono ja manda o id_token e nao passa por aqui
(2026-10-07); esta tela e para todo o resto. Ate 2026-10-08 era a do tema
BASE na moldura deste: "Saindo" / "Voce realmente deseja sair?" e o botao.
O botao precisa de `name="confirmLogout"` e o form do `session_code`: e o que
o Keycloak le no POST. O base desabilitava o botao no onsubmit, e um botao
desabilitado nao vai no POST — aqui nao se desabilita nada.
-->
<#import "template.ftl" as layout>
<@layout.registrationLayout; section>
<#if section = "selo">
<span class="selo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3"/><path d="M10 17l-5-5 5-5"/><path d="M5 12h11"/></svg></span>
<#elseif section = "header">
Sair da <span class="hl">conta</span>
<#elseif section = "sub">
${msg("logoutConfirmHeader")}
<#elseif section = "form">
<div id="kc-logout-confirm">
<form class="form-actions" action="${url.logoutConfirmAction}" method="POST">
<input type="hidden" name="session_code" value="${logoutConfirm.code}">
<button class="botao entrar" name="confirmLogout" id="kc-logout" type="submit" value="${msg("doLogout")}">
${msg("doLogout")}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg>
</button>
</form>
<div id="kc-info-message">
<#if !logoutConfirm.skipLink>
<#if (client.baseUrl)?has_content>
<a class="voltar" href="${client.baseUrl}">${layout.semSeta(msg("backToApplication"))}</a>
</#if>
</#if>
</div>
</div>
</#if>
</@layout.registrationLayout>
login-page-expired.ftl: |-
<#--
PAGINA EXPIRADA — o Keycloak mostra quando chega um formulario de uma etapa
que o login ja passou (voltar do navegador e reenviar, aba antiga).
O base dizia "Para reiniciar o processo de login [Clique aqui] . Para
continuar o processo de login [Clique aqui] ." — dois links soltos com ponto
depois. Aqui sao dois botoes: o principal recomeca (o que quase todo mundo
quer), o secundario continua de onde parou.
Os ids `loginRestartLink` e `loginContinueLink` sao os do base.
-->
<#import "template.ftl" as layout>
<@layout.registrationLayout; section>
<#if section = "selo">
<span class="selo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2"/><path d="M9.5 2.5h5"/></svg></span>
<#elseif section = "header">
A p&aacute;gina <span class="hl">expirou</span>
<#elseif section = "sub">
Esta etapa do login n&atilde;o vale mais. Escolha como seguir:
<#elseif section = "form">
<a id="loginRestartLink" class="botao entrar" href="${url.loginRestartFlowUrl}">Recome&ccedil;ar o login</a>
<a id="loginContinueLink" class="botao entrar secundario" href="${url.loginAction}">Continuar de onde parei</a>
</#if>
</@layout.registrationLayout>
login-reset-password.ftl: |-
<#--
ESQUECEU SUA SENHA — o link "Esqueceu sua senha?" do login cai aqui.
Ate 2026-10-08 era a pagina do tema BASE na moldura deste: campo sem o icone e
a altura do login, "« Voltar ao Login" ACIMA do botao e a instrucao no rodape.
Aqui a instrucao vira o subtitulo, o campo e o mesmo do login e o "voltar"
fica embaixo, onde se procura.
O Keycloak le `username` no POST. O id do form (`kc-reset-password-form`) e o
do base; o validador procura por ele. Depois de enviar, o Keycloak volta ao
LOGIN com o aviso "Voce devera receber um e-mail..." (mesmo para e-mail que
nao existe, para nao revelar quem tem conta) — essa tela e a login.ftl.
-->
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username'); section>
<#if section = "selo">
<span class="selo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="11" rx="2.4"/><path d="M8 10V7a4 4 0 0 1 7.6-1.7"/><path d="M12 14.5v2.5"/></svg></span>
<#elseif section = "header">
Esqueceu sua <span class="hl">senha?</span>
<#elseif section = "sub">
<#if realm.duplicateEmailsAllowed>${msg("emailInstructionUsername")}<#else>${msg("emailInstruction")}</#if>
<#elseif section = "form">
<form id="kc-reset-password-form" action="${url.loginAction}" method="post" novalidate>
<div class="campo">
<label class="rotulo" for="username"><#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if></label>
<div class="caixa">
<span class="icone">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2.5"/><path d="m3.5 7 8.5 6 8.5-6"/></svg>
</span>
<input type="text" id="username" name="username" autofocus autocomplete="username"
placeholder="seu@email.com" value="${(auth.attemptedUsername!'')}" dir="ltr"
aria-invalid="<#if messagesPerField.existsError('username')>true</#if>"
class="<#if messagesPerField.existsError('username')>erro</#if>"/>
</div>
<#if messagesPerField.existsError('username')>
<span id="input-error-username" class="erro-campo" aria-live="polite">${kcSanitize(messagesPerField.get('username'))?no_esc}</span>
</#if>
</div>
<button class="botao entrar" type="submit">
${msg("doSubmit")}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg>
</button>
</form>
<a class="voltar" href="${url.loginUrl}">${layout.semSeta(msg("backToLogin"))}</a>
</#if>
</@layout.registrationLayout>
login-config-totp.ftl: |-
<#--
CONFIGURAR O 2FA (TOTP) — o dono (`atm_admin`) cai aqui no primeiro login
depois do 2FA obrigatorio (fluxo `browser-2fa-dono`, 2026-10-07).
Ate 2026-10-08 era a pagina do tema BASE na moldura deste: funcionava, mas com
o "*" de obrigatorio numa linha sozinha, o passo 3 mandando "clicar em Ok"
num botao que se chama "Enviar", e o QR solto no texto.
NAO MUDE OS IDS: o robo do e2e (`frontend/e2e/apoio.ts`, passarPeloKeycloak)
acha a tela por `kc-totp-settings-form`, troca para a chave em texto por
`mode-manual`, le `kc-totp-secret-key` e preenche `totp`, `userLabel` e
`saveTOTPBtn`. O Keycloak le no POST: totp, totpSecret, userLabel, mode,
logout-sessions e cancel-aia.
`totp.policy.algorithmKey` e o getAlgorithmKey() do base, pela propriedade.
-->
<#import "template.ftl" as layout>
<@layout.registrationLayout displayRequiredFields=false displayMessage=!messagesPerField.existsError('totp','userLabel'); section>
<#if section = "selo">
<span class="selo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 4.5 6v5.5c0 4.6 3.1 7.9 7.5 9.5 4.4-1.6 7.5-4.9 7.5-9.5V6L12 3Z"/><path d="m9 12 2 2 4-4"/></svg></span>
<#elseif section = "header">
Verifica&ccedil;&atilde;o em <span class="hl">duas etapas</span>
<#elseif section = "sub">
Al&eacute;m da senha, cada login vai pedir um c&oacute;digo do aplicativo autenticador do seu celular.
<#elseif section = "form">
<ol id="kc-totp-settings" class="passos">
<li>
<p>${msg("loginTotpStep1")}</p>
<ul id="kc-totp-supported-apps" class="apps">
<#list totp.supportedApplications as app>
<li>${msg(app)}</li>
</#list>
</ul>
</li>
<#if mode?? && mode = "manual">
<li>
<p>${msg("loginTotpManualStep2")}</p>
<span id="kc-totp-secret-key" class="chave-manual">${totp.totpSecretEncoded}</span>
<ul class="config-manual">
<li id="kc-totp-type">${msg("loginTotpType")}: ${msg("loginTotp." + totp.policy.type)}</li>
<li id="kc-totp-algorithm">${msg("loginTotpAlgorithm")}: ${totp.policy.algorithmKey}</li>
<li id="kc-totp-digits">${msg("loginTotpDigits")}: ${totp.policy.digits}</li>
<#if totp.policy.type = "totp">
<li id="kc-totp-period">${msg("loginTotpInterval")}: ${totp.policy.period}</li>
<#elseif totp.policy.type = "hotp">
<li id="kc-totp-counter">${msg("loginTotpCounter")}: ${totp.policy.initialCounter}</li>
</#if>
</ul>
<p style="margin-top:10px"><a href="${totp.qrUrl}" id="mode-barcode" class="troca">${msg("loginTotpScanBarcode")}</a></p>
</li>
<#else>
<li>
<p>${msg("loginTotpStep2")}</p>
<span class="qr"><img id="kc-totp-secret-qr-code" src="data:image/png;base64, ${totp.totpSecretQrCode}" alt="C&oacute;digo QR para o aplicativo autenticador"></span>
<p style="margin-top:10px"><a href="${totp.manualUrl}" id="mode-manual" class="troca">${msg("loginTotpUnableToScan")}</a></p>
</li>
</#if>
<li>
<p>Digite o c&oacute;digo que o aplicativo mostra e d&ecirc; um nome a este celular.</p>
</li>
</ol>
<form action="${url.loginAction}" id="kc-totp-settings-form" method="post" novalidate>
<div class="campo">
<label class="rotulo" for="totp">${msg("authenticatorCode")}</label>
<div class="caixa">
<span class="icone">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="2.5" width="12" height="19" rx="2.5"/><path d="M11 18.5h2"/></svg>
</span>
<input type="text" id="totp" name="totp" autocomplete="one-time-code" inputmode="numeric"
placeholder="000000" dir="ltr" class="codigo<#if messagesPerField.existsError('totp')> erro</#if>"
aria-invalid="<#if messagesPerField.existsError('totp')>true</#if>"/>
</div>
<#if messagesPerField.existsError('totp')>
<span id="input-error-otp-code" class="erro-campo" aria-live="polite">${kcSanitize(messagesPerField.get('totp'))?no_esc}</span>
</#if>
<input type="hidden" id="totpSecret" name="totpSecret" value="${totp.totpSecret}"/>
<#if mode??><input type="hidden" id="mode" name="mode" value="${mode}"/></#if>
</div>
<div class="campo">
<label class="rotulo" for="userLabel">${msg("loginTotpDeviceName")}<#if !(totp.otpCredentials?size gte 1)> <span style="font-weight:400;color:var(--muted)">(opcional)</span></#if></label>
<div class="caixa">
<span class="icone">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h10l6 5-6 5H4z"/></svg>
</span>
<input type="text" id="userLabel" name="userLabel" autocomplete="off" placeholder="Ex.: meu celular" dir="ltr"
class="<#if messagesPerField.existsError('userLabel')>erro</#if>"
aria-invalid="<#if messagesPerField.existsError('userLabel')>true</#if>"/>
</div>
<#if messagesPerField.existsError('userLabel')>
<span id="input-error-otp-label" class="erro-campo" aria-live="polite">${kcSanitize(messagesPerField.get('userLabel'))?no_esc}</span>
</#if>
</div>
<label class="lembrar">
<input type="checkbox" id="logout-sessions" name="logout-sessions" value="on"> ${msg("logoutOtherSessions")}
</label>
<button type="submit" class="botao entrar" id="saveTOTPBtn">
Ativar
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg>
</button>
<#if isAppInitiatedAction??>
<button type="submit" class="botao entrar secundario" id="cancelTOTPBtn" name="cancel-aia" value="true">${msg("doCancel")}</button>
</#if>
</form>
</#if>
</@layout.registrationLayout>
login-otp.ftl: |-
<#--
DIGITAR O CODIGO DO 2FA — o dono, a cada login com senha (o login por
passkey dispensa; ver `browser-2fa-dono` no molde).
Ate 2026-10-08 era a pagina do tema BASE na moldura deste: titulo "Entrar",
como se fosse o login de novo, e um campo sem dizer de onde vem o codigo.
NAO MUDE OS IDS: o e2e (`frontend/e2e/apoio.ts`) acha a tela por
`kc-otp-login-form`, preenche `otp`, clica `kc-login` e detecta codigo
recusado por `input-error-otp-code`. O Keycloak le `otp` e, com mais de um
celular cadastrado, `selectedCredentialId`.
-->
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('totp'); section>
<#if section = "selo">
<span class="selo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="2.5" width="12" height="19" rx="2.5"/><path d="M11 18.5h2"/><path d="M9.5 9.5h5"/><path d="M9.5 12.5h5"/></svg></span>
<#elseif section = "header">
C&oacute;digo de <span class="hl">verifica&ccedil;&atilde;o</span>
<#elseif section = "sub">
Abra o aplicativo autenticador do seu celular e digite o c&oacute;digo que ele mostra agora.
<#elseif section = "form">
<form id="kc-otp-login-form" action="${url.loginAction}" method="post" novalidate>
<#if otpLogin.userOtpCredentials?size gt 1>
<div class="cartoes credenciais" role="radiogroup" aria-label="Celular">
<#list otpLogin.userOtpCredentials as otpCredential>
<div>
<input id="kc-otp-credential-${otpCredential?index}" type="radio" name="selectedCredentialId"
value="${otpCredential.id}" <#if otpCredential.id == otpLogin.selectedCredentialId>checked="checked"</#if>>
<label for="kc-otp-credential-${otpCredential?index}" class="cartao">
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="2.5" width="12" height="19" rx="2.5"/><path d="M11 18.5h2"/></svg></span>
<span class="txt"><b>${otpCredential.userLabel}</b></span>
</label>
</div>
</#list>
</div>
</#if>
<div class="campo">
<label class="rotulo" for="otp">${msg("loginOtpOneTime")}</label>
<div class="caixa">
<span class="icone">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="11" rx="2.4"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
</span>
<input id="otp" name="otp" type="text" autocomplete="one-time-code" inputmode="numeric" autofocus
placeholder="000000" dir="ltr" class="codigo<#if messagesPerField.existsError('totp')> erro</#if>"
aria-invalid="<#if messagesPerField.existsError('totp')>true</#if>"/>
</div>
<#if messagesPerField.existsError('totp')>
<span id="input-error-otp-code" class="erro-campo" aria-live="polite">${kcSanitize(messagesPerField.get('totp'))?no_esc}</span>
</#if>
</div>
<button class="botao entrar" name="login" id="kc-login" type="submit">
${msg("doLogIn")}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg>
</button>
</form>
</#if>
</@layout.registrationLayout>
webauthn-register.ftl: |-
<#--
CADASTRAR PASSKEY / CHAVE DE SEGURANCA — acoes obrigatorias
`webauthn-register-passwordless` (passkey, que dispensa a senha) e
`webauthn-register` (chave como segundo fator). As duas usam esta pagina.
Ate 2026-10-08 era a pagina do tema BASE na moldura deste: "Registrar Chave de
Seguranca" e um botao "Cadastre-se" — que parece criar CONTA, nao cadastrar a
chave.
O <script type="module"> e o do base, sem mudar uma linha: ele importa
`webauthnRegister.js` do tema base (que importa `rfc4648` pelo importmap do
template.ftl), chama o navegador e posta o form `register`. Os ids dos campos
escondidos e do botao `registerWebAuthn` sao lidos por esse JS.
-->
<#import "template.ftl" as layout>
<@layout.registrationLayout; section>
<#if section = "selo">
<span class="selo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M7.5 4.8A8.5 8.5 0 0 1 20.5 12c0 1.5-.2 3-.6 4.4"/><path d="M3.5 12a8.5 8.5 0 0 1 1.6-5"/><path d="M6.8 18.6A8.4 8.4 0 0 1 3.5 12"/><path d="M8 12a4 4 0 0 1 8 0c0 3-.6 5.6-1.8 7.8"/><path d="M12 12c0 3.4-.9 6.4-2.6 8.7"/></svg></span>
<#elseif section = "header">
Cadastre sua <span class="hl">chave de seguran&ccedil;a</span>
<#elseif section = "sub">
Nos pr&oacute;ximos logins, voc&ecirc; confirma que &eacute; voc&ecirc; com a digital, o rosto ou o PIN deste aparelho (ou com uma chave f&iacute;sica).
<#elseif section = "form">
<form id="register" action="${url.loginAction}" method="post">
<input type="hidden" id="clientDataJSON" name="clientDataJSON"/>
<input type="hidden" id="attestationObject" name="attestationObject"/>
<input type="hidden" id="publicKeyCredentialId" name="publicKeyCredentialId"/>
<input type="hidden" id="authenticatorLabel" name="authenticatorLabel"/>
<input type="hidden" id="transports" name="transports"/>
<input type="hidden" id="authenticatorAttachment" name="authenticatorAttachment"/>
<input type="hidden" id="error" name="error"/>
<label class="lembrar">
<input type="checkbox" id="logout-sessions" name="logout-sessions" value="on"> ${msg("logoutOtherSessions")}
</label>
</form>
<script type="module">
<#outputformat "JavaScript">
import { registerByWebAuthn } from "${url.resourcesPath}/js/webauthnRegister.js";
const registerButton = document.getElementById('registerWebAuthn');
registerButton.addEventListener("click", function() {
const input = {
challenge : ${challenge?c},
userid : ${userid?c},
username : ${username?c},
signatureAlgorithms : [<#list signatureAlgorithms as sigAlg>${sigAlg?c},</#list>],
rpEntityName : ${rpEntityName?c},
rpId : ${rpId?c},
attestationConveyancePreference : ${attestationConveyancePreference?c},
authenticatorAttachment : ${authenticatorAttachment?c},
requireResidentKey : ${requireResidentKey?c},
residentKey : ${residentKey?c},
userVerificationRequirement : ${userVerificationRequirement?c},
createTimeout : ${createTimeout?c},
excludeCredentialIds : ${excludeCredentialIds?c},
initLabel : ${msg("webauthn-registration-init-label")?c},
initLabelPrompt : ${msg("webauthn-registration-init-label-prompt")?c},
errmsg : ${msg("webauthn-unsupported-browser-text")?c}
};
registerByWebAuthn(input);
}, { once: true });
</#outputformat>
</script>
<button type="button" class="botao entrar" id="registerWebAuthn" style="margin-top:20px">
Cadastrar chave de seguran&ccedil;a
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg>
</button>
<#if !isSetRetry?has_content && isAppInitiatedAction?has_content>
<form action="${url.loginAction}" id="kc-webauthn-settings-form" method="post" style="margin-top:0">
<button type="submit" class="botao entrar secundario" id="cancelWebAuthnAIA" name="cancel-aia" value="true">${msg("doCancel")}</button>
</form>
</#if>
</#if>
</@layout.registrationLayout>
webauthn-authenticate.ftl: |-
<#--
ENTRAR COM PASSKEY / CHAVE DE SEGURANCA — a etapa WebAuthn de um fluxo
(quando o Keycloak pede a chave numa tela propria, e nao pela sugestao no
campo de e-mail do login.ftl).
Ate 2026-10-08 era a pagina do tema BASE na moldura deste: a lista de chaves
vinha com classes do PatternFly que o tema nao carrega, e saia como texto
corrido.
O <script type="module"> e o do base, sem mudar uma linha (importa
`webauthnAuthenticate.js` do tema base; o `rfc4648` vem do importmap do
template.ftl). O JS le os forms `webauthn` e `authn_select` e o botao
`authenticateWebAuthnButton` pelos ids.
-->
<#import "template.ftl" as layout>
<@layout.registrationLayout displayInfo=(realm.registrationAllowed && !registrationDisabled??); section>
<#if section = "selo">
<span class="selo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M7.5 4.8A8.5 8.5 0 0 1 20.5 12c0 1.5-.2 3-.6 4.4"/><path d="M3.5 12a8.5 8.5 0 0 1 1.6-5"/><path d="M6.8 18.6A8.4 8.4 0 0 1 3.5 12"/><path d="M8 12a4 4 0 0 1 8 0c0 3-.6 5.6-1.8 7.8"/><path d="M12 12c0 3.4-.9 6.4-2.6 8.7"/></svg></span>
<#elseif section = "header">
Entrar com <span class="hl">chave de seguran&ccedil;a</span>
<#elseif section = "sub">
Confirme que &eacute; voc&ecirc; com a digital, o rosto ou o PIN do aparelho (ou com a sua chave f&iacute;sica).
<#elseif section = "form">
<div id="kc-form-webauthn">
<form id="webauth" action="${url.loginAction}" method="post" style="display:none">
<input type="hidden" id="clientDataJSON" name="clientDataJSON"/>
<input type="hidden" id="authenticatorData" name="authenticatorData"/>
<input type="hidden" id="signature" name="signature"/>
<input type="hidden" id="credentialId" name="credentialId"/>
<input type="hidden" id="userHandle" name="userHandle"/>
<input type="hidden" id="error" name="error"/>
</form>
<#if authenticators??>
<form id="authn_select" style="display:none">
<#list authenticators.authenticators as authenticator>
<input type="hidden" name="authn_use_chk" value="${authenticator.credentialId}"/>
</#list>
</form>
<#if shouldDisplayAuthenticators?? && shouldDisplayAuthenticators>
<#if authenticators.authenticators?size gt 1>
<p class="rotulo">${msg("webauthn-available-authenticators")}</p>
</#if>
<div class="cartoes" style="margin-bottom:18px">
<#list authenticators.authenticators as authenticator>
<div id="kc-webauthn-authenticator-item-${authenticator?index}" class="cartao estatico">
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="15" r="4"/><path d="m10.8 12.2 8.7-8.7"/><path d="m17 6 2.5 2.5"/><path d="m14.5 8.5 2 2"/></svg></span>
<span class="txt">
<b id="kc-webauthn-authenticator-label-${authenticator?index}">${authenticator.label}</b>
<#if authenticator.transports?? && authenticator.transports.displayNameProperties?has_content>
<span id="kc-webauthn-authenticator-transport-${authenticator?index}"><#list authenticator.transports.displayNameProperties as nameProperty>${msg(nameProperty)}<#sep>, </#list></span>
</#if>
<span><span id="kc-webauthn-authenticator-createdlabel-${authenticator?index}">${msg('webauthn-createdAt-label')}</span>
<span id="kc-webauthn-authenticator-created-${authenticator?index}">${authenticator.createdAt}</span></span>
</span>
</div>
</#list>
</div>
</#if>
</#if>
<button id="authenticateWebAuthnButton" type="button" autofocus class="botao entrar">
${msg("webauthn-doAuthenticate")}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg>
</button>
</div>
<script type="module">
<#outputformat "JavaScript">
import { authenticateByWebAuthn } from "${url.resourcesPath}/js/webauthnAuthenticate.js";
const authButton = document.getElementById('authenticateWebAuthnButton');
authButton.addEventListener("click", function() {
const input = {
isUserIdentified : ${isUserIdentified},
challenge : ${challenge?c},
userVerification : ${userVerification?c},
rpId : ${rpId?c},
createTimeout : ${createTimeout?c},
errmsg : ${msg("webauthn-unsupported-browser-text")?c}
};
authenticateByWebAuthn(input);
}, { once: true });
</#outputformat>
</script>
<#elseif section = "info">
<#if realm.registrationAllowed && !registrationDisabled??>
<div id="kc-registration">
<span>${msg("noAccount")} <a tabindex="6" href="${url.registrationUrl}">${msg("doRegister")}</a></span>
</div>
</#if>
</#if>
</@layout.registrationLayout>
select-authenticator.ftl: |-
<#--
ESCOLHER COMO ENTRAR — o "Tente outra forma" de um fluxo com alternativas
(senha, passkey, codigo do celular...).
Ate 2026-10-08 era a pagina do tema BASE na moldura deste: cada opcao e um
<button>, e o `.conteudo button` do tema (o botao de acao, laranja) pintava
TODAS de laranja, com os icones do PatternFly — que o tema nao carrega — em
branco. Aqui cada opcao e um cartao branco com icone proprio.
O Keycloak le `authenticationExecution` (o value do botao clicado). O id do
form `kc-select-credential-form` e o do base.
O icone vem do `iconCssClass` da opcao (kcAuthenticatorPasswordClass,
kcAuthenticatorOTPClass, kcAuthenticatorWebAuthnPasswordlessClass...): o nome
da classe diz o tipo, e e so isso que se usa dela.
-->
<#import "template.ftl" as layout>
<@layout.registrationLayout displayInfo=false; section>
<#if section = "selo">
<span class="selo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="15" r="4"/><path d="m10.8 12.2 8.7-8.7"/><path d="m17 6 2.5 2.5"/><path d="m14.5 8.5 2 2"/></svg></span>
<#elseif section = "header">
Como voc&ecirc; quer <span class="hl">entrar?</span>
<#elseif section = "sub">
${msg("loginChooseAuthenticator")}
<#elseif section = "form">
<form id="kc-select-credential-form" action="${url.loginAction}" method="post">
<div class="cartoes">
<#list auth.authenticationSelections as authenticationSelection>
<#assign tipo = (authenticationSelection.iconCssClass!'')?lower_case>
<button class="cartao" type="submit" name="authenticationExecution" value="${authenticationSelection.authExecId}">
<span class="ico">
<#if tipo?contains("passkey") || tipo?contains("webauthn")>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M7.5 4.8A8.5 8.5 0 0 1 20.5 12c0 1.5-.2 3-.6 4.4"/><path d="M3.5 12a8.5 8.5 0 0 1 1.6-5"/><path d="M6.8 18.6A8.4 8.4 0 0 1 3.5 12"/><path d="M8 12a4 4 0 0 1 8 0c0 3-.6 5.6-1.8 7.8"/><path d="M12 12c0 3.4-.9 6.4-2.6 8.7"/></svg>
<#elseif tipo?contains("otp")>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="2.5" width="12" height="19" rx="2.5"/><path d="M11 18.5h2"/></svg>
<#elseif tipo?contains("recovery")>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="4" width="14" height="17" rx="2"/><path d="M9 9h6"/><path d="M9 13h6"/><path d="M9 17h3"/></svg>
<#elseif tipo?contains("password")>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="11" rx="2.4"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
<#else>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 4.5 6v5.5c0 4.6 3.1 7.9 7.5 9.5 4.4-1.6 7.5-4.9 7.5-9.5V6L12 3Z"/></svg>
</#if>
</span>
<span class="txt">
<b>${msg('${authenticationSelection.displayName}')}</b>
<span>${msg('${authenticationSelection.helpText}')}</span>
</span>
<span class="seta"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></svg></span>
</button>
</#list>
</div>
</form>
</#if>
</@layout.registrationLayout>