8000 Unify login theme actions and styling hooks by kathari00 · Pull Request #52724 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/documentation/upgrading/topics/changes/changes-27_0_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,20 @@ WARNING: With *Allow exchange token expiration after subject token expiration* o

See the link:{securing_apps_token_exchange_link}#_standard-token-exchange-expiration[{securing_apps_token_exchange_name}] for more information.

=== User Interface

==== New Base login theme
// https://github.com/keycloak/keycloak/issues/54546
// https://github.com/keycloak/keycloak/issues/54547

The new Base login theme provides reusable templates and components for current and future login themes, with styling configurable through theme properties.
Custom themes can override individual macros or properties instead of copying complete pages, reducing maintenance and helping them inherit validation, accessibility, and authentication improvements.
The `keycloak.v2` login theme now inherits these shared templates and retains its styling through theme properties.

The previous Base login templates are no longer provided.
Custom login themes with `parent=base` now inherit the new markup.
Review and update their template overrides and CSS to match the new structure.
Comment thread
mabartos marked this conversation as resolved.
Resolved

// ------------------------ Notable changes ------------------------ //
== Notable changes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@

import org.keycloak.testframework.ui.webdriver.ManagedWebDriver;

import org.openqa.selenium.Keys;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;

Expand All @@ -29,15 +28,15 @@
*/
public class LogoutConfirmPage extends AbstractLoginPage {

@FindBy(css = "input[type=\"submit\"]")
@FindBy(id = "kc-logout")
private WebElement confirmLogoutButton;

public LogoutConfirmPage(ManagedWebDriver driver) {
super(driver);
}

public void confirmLogout() {
confirmLogoutButton.sendKeys(Keys.ENTER);
confirmLogoutButton.click();
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@

import org.junit.jupiter.api.Assertions;
import org.openqa.selenium.By;
import org.openqa.selenium.Keys;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
Expand Down Expand Up @@ -59,7 +58,7 @@ public class RegisterPage extends AbstractLoginPage {
@FindBy(name = "termsAccepted")
private WebElement termsAcceptedInput;

@FindBy(css = "input[type=\"submit\"]")
@FindBy(css = "[type=\"submit\"]")
private WebElement submitButton;

@FindBy(linkText = "« Back to Login")
Expand Down Expand Up @@ -152,7 +151,7 @@ public void register(String firstName, String lastName, String email, String use
}
}

submitButton.sendKeys(Keys.ENTER);
submitButton.click();
}

public String getFirstName() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ public static class TotpSetupForm extends Form {
@FindBy(id = "totpSecret")
private WebElement totpSecret;

@FindBy(xpath = ".//input[@value='Submit']")
@FindBy(id = "saveTOTPBtn")
private WebElement submit;

public void setTotp(String value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
*/
public class LogoutConfirmPage extends LanguageComboboxAwarePage {

@FindBy(css = "input[type=\"submit\"]")
@FindBy(id = "kc-logout")
private WebElement confirmLogoutButton;

@FindBy(linkText = "« Back to Application")
Expand All @@ -47,7 +47,7 @@ public void confirmLogout() {
}

public void confirmLogout(WebDriver driver) {
UIUtils.clickLink(driver.findElement(By.cssSelector("input[type=\"submit\"]")));
UIUtils.clickLink(driver.findElement(By.id("kc-logout")));
}

public void clickBackToApplicationLink() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public class RegisterPage extends LanguageComboboxAwarePage
@FindBy(name = "termsAccepted")
private WebElement termsAcceptedInput;

@FindBy(css = "input[type=\"submit\"]")
@FindBy(css = "[type=\"submit\"]")
private WebElement submitButton;

@FindBy(css = "div[class^='pf-v5-c-alert'], div[class^='alert-error']")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public class UpdateAccountInformationPage extends LanguageComboboxAwarePage {
@FindBy(name = "department")
private WebElement departmentInput;

@FindBy(css = "input[type=\"submit\"]")
@FindBy(css = "[type=\"submit\"]")
private WebElement submitButton;

public void updateAccountInformation(String userName,
Expand Down
32 changes: 32 additions & 0 deletions themes/src/main/resources/theme/base/login/buttons.ftl
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<#-- Wrap related actions with <@buttons.actionGroup horizontal=true>; group=false omits the field-group wrapper. -->
<#macro actionGroup horizontal=false id="" group=true>
<#if group><div class="${properties.kcFormGroupClass!}"></#if>
<div<#if id?has_content> id="${id}"</#if> class="${properties.kcFormActionGroupClass!(properties.kcFormButtonsClass!'')}<#if horizontal> ${properties.kcFormActionGroupHorizontalClass!}<#else> ${properties.kcFormActionGroupVerticalClass!}</#if>">
<#nested>
</div>
<#if group></div></#if>
</#macro>

<#-- Use <@buttons.button label="doContinue"/>; type selects the style, and extra attributes are forwarded. -->
<#macro button label id="" name="" type="primary" fullWidth=true class=[] className="" labelText="" attributes={} extra...>
<#local variantClass = properties['kcButton' + type?cap_first + 'Class']!>
<#if type == "secondary" && !properties.kcButtonSecondaryClass??>
<#local variantClass = properties.kcButtonDefaultClass!>
</#if>
<button class="${properties.kcButtonClass!} ${variantClass}<#if fullWidth> ${properties.kcButtonBlockClass!}</#if><#list class as c> ${properties[c]!}</#list><#if className?has_content> ${className}</#if>"<#if name?has_content> name="${name}"</#if><#if id?has_content> id="${id}"</#if>
type="submit" <#list attributes as attrName, attrVal>${attrName}="${attrVal}" </#list><#list extra as attrName, attrVal>${attrName}="${attrVal}" </#list>>
<#if labelText?has_content>${labelText}<#else>${msg(label)}</#if>
</button>
</#macro>

<#-- Use <@buttons.buttonLink href=url.loginUrl label="backToLogin"/>; class lists theme-property names. -->
<#macro buttonLink href label id="" class=["kcButtonSecondaryClass", "kcButtonBlockClass"]>
<a<#if id?has_content> id="${id}"</#if> href="${href}" class="${properties.kcButtonClass!}<#list class as c> <#if c == "kcButtonSecondaryClass">${properties[c]!(properties.kcButtonDefaultClass!'')}<#else>${properties[c]!}</#if></#list>">${msg(label)}</a>
</#macro>

<#-- Use <@buttons.loginButton/> for the standard login action and its action-group wrapper. -->
<#macro loginButton>
<@buttons.actionGroup>
<@buttons.button id="kc-login" name="login" label="doLogIn" />
</@buttons.actionGroup>
</#macro>
84 changes: 81 additions & 3 deletions themes/src/main/resources/theme/base/login/code.ftl
Original file line number Diff line number Diff line change
@@ -1,18 +1,96 @@
<#import "template.ftl" as layout>
<#import "field.ftl" as field>
<@layout.registrationLayout; section>
<#if section = "header">
<#if code.success>
${msg("codeSuccessTitle")}
<#else>
${kcSanitize(msg("codeErrorTitle", code.error))}
${msg("codeErrorTitle", code.error)}
</#if>
<#elseif section = "form">
<div id="kc-code">
<#if code.success>
<p>${msg("copyCodeInstruction")}</p>
<input id="code" class="${properties.kcTextareaClass!}" value="${code.code}"/>
<@field.clipboard name="code" label="" ariaLabel=msg("codeSuccess") value=code.code />
<script type="module">
<#outputformat "JavaScript">
(() => {
const copyButton = document.getElementById('kc-code-copy-button');
const toggleButton = document.getElementById('kc-code-toggle');
const input = document.getElementById('kc-code');
const expandableContent = document.getElementById('kc-code-content');
const clipboardContainer = document.getElementById('kc-code-clipboard');

// Validate elements
if (!copyButton || !toggleButton || !input || !expandableContent || !clipboardContainer) {
console.error("Missing required DOM elements for code interactions.");
return;
}

// Validate Clipboard API
if (!navigator.clipboard) {
console.error("Clipboard API not supported in this browser.");
copyButton.style.display = 'none';
return;
}

// Handle copy functionality
copyButton.addEventListener('click', async() => {
const originalIcon = copyButton.innerHTML;

// Get value from expandable content
let value = expandableContent.querySelector('code')?.textContent;

try {
await navigator.clipboard.writeText(value);
updateCopyButton(true, copyButton, originalIcon);
} catch (err) {
console.error('Copy failed: ', err);
updateCopyButton(false, copyButton, originalIcon);
}
});

// Handle toggle functionality
toggleButton.addEventListener('click', () => {
const newExpanded = !(toggleButton.getAttribute('aria-expanded') === 'true');

toggleButton.setAttribute('aria-expanded', newExpanded.toString());
expandableContent.hidden = !newExpanded;

// Update icon
const icon = toggleButton.querySelector('#kc-code-toggle-icon');
if (icon) {
icon.className = newExpanded ? toggleButton.dataset.iconExpandedClass : toggleButton.dataset.iconCollapsedClass;
}

// Update clipboard container class
const expandedClass = toggleButton.dataset.expandedClass;
if (expandedClass) {
clipboardContainer.classList.toggle(expandedClass, newExpanded);
}
});
})();

// Update copy button to show success or failure state
function updateCopyButton(success, button, originalIcon) {
button.setAttribute('aria-label', success ? button.dataset.successLabel : button.dataset.failureLabel);
const icon = button.querySelector('#kc-code-copy-icon');
if (icon) {
icon.className = success ? button.dataset.iconSuccess : button.dataset.iconFailure;
}
button.disabled = true;

// Reset button after 2 seconds
setTimeout(() => {
button.innerHTML = originalIcon;
button.setAttribute('aria-label', ${msg("code-copy-label")?c});
button.disabled = false;
}, 2000);
}
</#outputformat>
</script>
<#else>
<p id="error">${kcSanitize(code.error)}</p>
<p id="error">${code.error}</p>
</#if>
</div>
</#if>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,39 @@
<#import "template.ftl" as layout>
<#import "buttons.ftl" as buttons>

<@layout.registrationLayout; section>
<!-- template: delete-account-confirm.ftl -->

<#if section = "header">
${msg("deleteAccountConfirm")}
${msg("deleteAccountConfirm")}

<#elseif section = "form">

<form action="${url.loginAction}" class="form-vertical" method="post">

<div class="alert alert-warning kc-alert-delete-account">
<span class="pficon pficon-warning-triangle-o"></span>
${msg("irreversibleAction")}
</div>

<p>${msg("deletingImplies")}</p>
<ul class="kc-delete-account-list">
<li>${msg("loggingOutImmediately")}</li>
<li>${msg("errasingData")}</li>
</ul>

<p class="delete-account-text">${msg("finalDeletionConfirmation")}</p>

<div id="kc-form-buttons">
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" type="submit" value="${msg("doConfirmDelete")}" />
<#if triggered_from_aia>
<button class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!} kc-delete-account-cancel" type="submit" name="cancel-aia" value="true">${msg("doCancel")}</button>
</#if>
</div>
<form action="${url.loginAction}" class="${properties.kcFormClass!}" id="kc-deleteaccount-form" method="post">
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As the form is one of the fundamentals of our login pages, maybe we could investigate if also a macro for form with some defaults could be used? :P

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... And yes

I thought about it as well but wasn't sure if I could flip the whole table.

The best case scenario would be for people not to overwrite template.ftl.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kathari00 There will be probably some follow-ups, so it can be done later.

The best case scenario would be for people not to overwrite template.ftl.

Yep, it could be good to make the template.ftl smarter for the new base theme. Do you know what is most frequently updated on the template.ftl? This might be one candidate: #48225


<div class="${properties.kcAlertClass!} ${properties.kcAlertWarningClass!}">
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we also introduce a macro for alerts? Could be also nice if our macros would contain some comments on how to use them (briefly).

WDYT?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There will be probably some follow-ups, so it can be done later.

<div class="${properties.kcAlertIconClass!}">
<i class="${properties.kcFeedbackWarningIcon!}" aria-hidden="true"></i>
</div>
<span class="${properties.kcAlertTitleClass!}">
${msg("irreversibleAction")}
</span>
</div>

<p>${msg("deletingImplies")}</p>
<ul class="${properties.kcListClass!}" role="list">
<li>${msg("loggingOutImmediately")}</li>
<li>${msg("errasingData")}</li>
</ul>

<p class="delete-account-text">${msg("finalDeletionConfirmation")}</p>

<@buttons.actionGroup>
<@buttons.button id="kc-submit" label="doConfirmDelete"/>
<#if triggered_from_aia>
<@buttons.button id="kc-cancel" name="cancel-aia" label="doCancel" type="secondary"/>
</#if>
</@buttons.actionGroup>
</form>
</#if>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
<#import "template.ftl" as layout>
<#import "buttons.ftl" as buttons>

<@layout.registrationLayout displayMessage=false; section>
<!-- template: delete-credential.ftl -->

<#if section = "header">
${msg("deleteCredentialTitle", credentialLabel)}
<#elseif section = "form">
<div id="kc-delete-text">
${msg("deleteCredentialMessage", credentialLabel)}
</div>
<form class="form-actions" action="${url.loginAction}" method="POST">
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" name="accept" id="kc-accept" type="submit" value="${msg("doConfirmDelete")}"/>
<input class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!}" name="cancel-aia" value="${msg("doCancel")}" id="kc-decline" type="submit" />
</form>
<div class="clearfix"></div>
<form class="${properties.kcFormClass!}" action="${url.loginAction}" method="POST">
<p id="kc-delete-text">
${msg("deleteCredentialMessage", credentialLabel)}
</p>
<@buttons.actionGroup horizontal=true>
<@buttons.button name="accept" id="kc-accept" label="doConfirmDelete"/>
<@buttons.button name="cancel-aia" id="kc-decline" label="doDecline" type="secondary"/>
</@buttons.actionGroup>
</form>
</#if>
</@layout.registrationLayout>
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<#-- Use <@divider.or/> between alternative actions, or pass text to replace the default label. -->
<#macro or text=msg("orLabel")>
<div class="${properties.kcLoginDivider!}">
<span class="${properties.kcLoginMainFooterHelperText!}">
Expand Down
3 changes: 2 additions & 1 deletion themes/src/main/resources/theme/base/login/error.ftl
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<#import "template.ftl" as layout>
<#import "buttons.ftl" as buttons>
<@layout.registrationLayout displayMessage=false; section>
<#if section = "header">
${kcSanitize(msg("errorTitle"))?no_esc}
Expand All @@ -11,7 +12,7 @@
<#if skipLink??>
<#else>
<#if client?? && client.baseUrl?has_content>
<p><a id="backToApplication" href="${client.baseUrl}">${msg("backToApplication")}</a></p>
<p><@buttons.buttonLink id="backToApplication" href=client.baseUrl label="backToApplication"/></p>
</#if>
</#if>
</div>
Expand Down
Loading
Loading
0