8000 Align Keycloak v3 login pages with PatternFly 6 by mabartos · Pull Request #55115 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,6 @@ public class OTPFormAuthenticator extends AbstractUsernameFormAuthenticator impl
// Freemarker attribute where selected OTP credential will be stored
public static final String SELECTED_OTP_CREDENTIAL_ID = "selectedOtpCredentialId";

// Label to be shown in the UI for the "unnamed" OTP credential, which doesn't have userLabel
public static final String UNNAMED = "unnamed";


@Override
public void action(AuthenticationFlowContext context) {
validateOTP(context);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
import java.util.List;
import java.util.stream.Collectors;

import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticator;
import org.keycloak.credential.CredentialModel;
import org.keycloak.credential.CredentialProvider;
import org.keycloak.credential.OTPCredentialProvider;
Expand Down Expand Up @@ -82,8 +81,8 @@ public static class OTPCredential {

public OTPCredential(CredentialModel credentialModel) {
this.id = credentialModel.getId();
// TODO: "Unnamed" OTP credentials should be displayed in the UI in gray
this.userLabel = credentialModel.getUserLabel() == null || credentialModel.getUserLabel().isEmpty() ? OTPFormAuthenticator.UNNAMED : credentialModel.getUserLabel();
// TODO: Credentials without a name should be displayed in the UI in gray
this.userLabel = credentialModel.getUserLabel();
}

public String getId() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public String getInputError() {
// If false, we don't expect that credentials combobox is available. If true, we expect that it is available on the page
public void assertOtpCredentialSelectorAvailability(boolean expectedAvailability) {
try {
driver.findElement(By.className("pf-v6-c-tile"));
driver.findElement(getCssSelectorForLookupAllRadios());
Assertions.assertTrue(expectedAvailability);
} catch (NoSuchElementException nse) {
Assertions.assertFalse(expectedAvailability);
Expand All @@ -90,7 +90,7 @@ public List<String> getAvailableOtpCredentials() {
public String getSelectedOtpCredential() {
try {
WebElement selected = driver.findElement(getCssSelectorForLookupActiveCard());
return selected.getText();
return driver.findElement(By.id(selected.getAttribute("aria-labelledby"))).getText();
} catch (NoSuchElementException nse) {
// No selected element found
return null;
Expand All @@ -103,15 +103,19 @@ public void selectOtpCredential(String credentialName) {
webElement.click();
}

private By getCssSelectorForLookupAllRadios() {
return By.cssSelector("input[type='radio'][name='selectedCredentialId']");
}

private By getXPathForLookupAllCards() {
return By.xpath("//span[contains(@class, 'pf-v6-c-tile__title')]");
return By.xpath("//*[starts-with(@id, 'kc-otp-credential-title-')]");
}

private By getCssSelectorForLookupActiveCard() {
return By.cssSelector(".pf-m-selected");
return By.cssSelector("input[name='selectedCredentialId']:checked");
}

private By getXPathForLookupCardWithName(String credentialName) {
return By.xpath("//div[contains(@class, 'pf-v6-c-tile')][normalize-space() = '"+ credentialName +"']");
return By.xpath("//input[@name='selectedCredentialId'][@aria-labelledby = //*[starts-with(@id, 'kc-otp-credential-title-')][normalize-space() = '" + credentialName + "']/@id]/following-sibling::label");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ public String getExpectedPageId() {
// If false, we don't expect that credentials combobox is available. If true, we expect that it is available on the page
public void assertOtpCredentialSelectorAvailability(boolean expectedAvailability) {
try {
driver.findElement(By.className("pf-v6-c-tile"));
driver.findElement(getCssSelectorForLookupAllRadios());
Assertions.assertTrue(expectedAvailability);
} catch (NoSuchElementException nse) {
Assertions.assertFalse(expectedAvailability);
Expand All @@ -90,23 +90,27 @@ public List<String> getAvailableOtpCredentials() {
public String getSelectedOtpCredential() {
try {
WebElement selected = driver.findElement(getCssSelectorForLookupActiveCard());
return selected.getText();
return driver.findElement(By.id(selected.getAttribute("aria-labelledby"))).getText();
} catch (NoSuchElementException nse) {
// No selected element found
return null;
}
}

private By getCssSelectorForLookupAllRadios() {
return By.cssSelector("input[type='radio'][name='selectedCredentialId']");
}

private By getXPathForLookupAllCards() {
return By.xpath("//span[contains(@class, 'pf-v6-c-tile__title')]");
return By.xpath("//*[starts-with(@id, 'kc-otp-credential-title-')]");
}

private By getCssSelectorForLookupActiveCard() {
return By.cssSelector(".pf-m-selected");
return By.cssSelector("input[name='selectedCredentialId']:checked");
}

private By getXPathForLookupCardWithName(String credentialName) {
return By.xpath("//div[contains(@class, 'pf-v6-c-tile')][normalize-space() = '"+ credentialName +"']");
return By.xpath("//input[@name='selectedCredentialId'][@aria-labelledby = //*[starts-with(@id, 'kc-otp-credential-title-')][normalize-space() = '" + credentialName + "']/@id]/following-sibling::label");
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public String getExpectedPageId() {
}

public void selectOtp(int index) {
driver.findElement(By.id("kc-otp-credential-" + index)).click();
driver.findElement(By.cssSelector("label[for='kc-otp-credential-" + index + "']")).click();
}

public void submitOtpReset() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticator;
import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticatorFactory;
import org.keycloak.authentication.authenticators.browser.PasswordFormFactory;
import org.keycloak.authentication.authenticators.browser.UsernameFormFactory;
Expand Down Expand Up @@ -190,7 +189,7 @@ public void testUserWithTwoAdditionalFactors() {
Assertions.assertEquals("first", loginTotpPage.getSelectedOtpCredential());

// Select "second" factor (which is unnamed as it doesn't have userLabel) but try to connect with the OTP code from the "first" one
loginTotpPage.selectOtpCredential(OTPFormAuthenticator.UNNAMED);
loginTotpPage.selectOtpCredential("Default");
loginTotpPage.login(getOtpCode(USER_WITH_TWO_OTPS_OTP1_SECRET));
Assertions.assertEquals(INVALID_AUTH_CODE, oneTimeCodePage.getInputError());

Expand All @@ -200,11 +199,33 @@ public void testUserWithTwoAdditionalFactors() {
Assertions.assertEquals(INVALID_AUTH_CODE, oneTimeCodePage.getInputError());

// Select "second" factor and try to connect with its OTP code
loginTotpPage.selectOtpCredential(OTPFormAuthenticator.UNNAMED);
loginTotpPage.selectOtpCredential("Default");
loginTotpPage.login(getOtpCode(USER_WITH_TWO_OTPS_OTP2_SECRET));
Assertions.assertFalse(oneTimeCodePage.isOtpLabelPresent());
}

@Test
public void testOtpCredentialLabelIsNotLocalized() {
String username = "user-with-two-configured-otp";
String userLabel = "${defaultOtpAuth}";
UserResource user = AdminApiUtil.findUserByUsernameId(managedRealm.admin(), username);
String credentialId = user.credentials().stream()
.filter(credential -> "first".equals(credential.getUserLabel()))
.findFirst().orElseThrow().getId();

try {
user.setCredentialUserLabel(credentialId, userLabel);
provideUsernamePassword(username);
loginTotpPage.assertCurrent();
Assertions.assertEquals(Arrays.asList(userLabel, "Default"), loginTotpPage.getAvailableOtpCredentials());
loginTotpPage.selectOtpCredential(userLabel);
loginTotpPage.login(getOtpCode(USER_WITH_TWO_OTPS_OTP1_SECRET));
Assertions.assertTrue(oauth.parseLoginResponse().isSuccess());
} finally {
user.setCredentialUserLabel(credentialId, "first");
}
}

private void testCredentialsOrder(String username, List<String> orderedCredentials) {
// Provide username and password
provideUsernamePassword(username);
Expand All @@ -227,7 +248,7 @@ public void testCredentialsOrder() {
int idxFirst = 0; // Credentials order is: first, password, second

// Priority tells: first then second
testCredentialsOrder(username, Arrays.asList("first", OTPFormAuthenticator.UNNAMED));
testCredentialsOrder(username, Arrays.asList("first", "Default"));

try {
// Move first credential in last position
Expand All @@ -239,7 +260,7 @@ public void testCredentialsOrder() {
changePasswords(username);

// Priority tells: second then first
testCredentialsOrder(username, Arrays.asList(OTPFormAuthenticator.UNNAMED, "first"));
testCredentialsOrder(username, Arrays.asList("Default", "first"));
} finally {
// Restore default testrealm.json
testRealmReps = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.authentication.AuthenticationFlow;
import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticator;
import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticatorFactory;
import org.keycloak.authentication.authenticators.browser.RecoveryAuthnCodesFormAuthenticatorFactory;
import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory;
Expand Down Expand Up @@ -820,7 +819,7 @@ public void testWithMultipleOTPCodes() throws Exception {
loginTotpPage.assertCurrent();
loginTotpPage.assertOtpCredentialSelectorAvailability(true);
List<String> availableOtps = loginTotpPage.getAvailableOtpCredentials();
Assert.assertNames(availableOtps, OTPFormAuthenticator.UNNAMED, "totp2-label");
Assert.assertNames(availableOtps, "Default", "totp2-label");

// Removing 2nd OTP by account REST API with regular token. Should fail as acr=2 is required
String otpCredentialId;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

<form action="${url.loginAction}" class="${properties.kcFormClass!}" id="kc-deleteaccount-form" method="post">

<div class="${properties.kcAlertClass!} ${properties.kcAlertWarningClass!}">
<div class="${properties.kcAlertInFormClass!properties.kcAlertClass!} ${properties.kcAlertWarningClass!}">
<div class="${properties.kcAlertIconClass!}">
<i class="${properties.kcFeedbackWarningIcon!}" aria-hidden="true"></i>
</div>
Expand All @@ -37,4 +37,4 @@
</form>
</#if>

</@layout.registrationLayout>
</@layout.registrationLayout>
91 changes: 45 additions & 46 deletions themes/src/main/resources/theme/base/login/field.ftl
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<#-- Nest a custom control in <@field.group name=&quo 10BC8 t;field-id" label=msg("labelKey")> for its label and errors. -->
<#macro group name label error="" required=false errorId="input-error-${name}">
<#macro group name label error="" required=false errorId="input-error-${name}" renderError=true>

<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcFormGroupLabelClass!}">
Expand All @@ -16,7 +16,7 @@
<div class="${properties.kcFormGroupControlClass!}">
<#nested>

<@errorMessage name=name error=error errorId=errorId/>
<#if renderError><@errorMessage name=name error=error errorId=errorId/></#if>
</div>
</div>

Expand Down Expand Up @@ -67,7 +67,7 @@
<#-- Use <@field.password name="password" label=msg("password")>; nest optional helper text or controls. -->
<#macro password name label value="" required=false forgotPassword=false fieldName=name error=messagesPerField.get(fieldName) autocomplete="off" autofocus=false>
<#local helperItems><#compress><#nested></#compress></#local>
<@group name=name label=label error=error required=required>
<@group name=name label=label error=error required=required renderError=false>
<div class="${properties.kcInputGroup!}">
<div class="${properties.kcInputGroupItemClass!} ${properties.kcFill!}">
<span class="${properties.kcInputClass!} <#if error?has_content>${properties.kcError!}</#if>">
Expand All @@ -88,6 +88,7 @@
</button>
</div>
</div>
<@errorMessage name=name error=error/>
<#if helperItems?has_content || forgotPassword>
<div class="${properties.kcFormHelperTextClass!}" aria-live="polite">
<div class="${properties.kcInputHelperTextClass!}">
Expand All @@ -114,51 +115,49 @@
<#-- Use <@field.clipboard name="code" label=msg("labelKey") value=code/> for copy and expand controls. -->
<#macro clipboard name label ariaLabel=label value="" readonly=true>
<@group name=name label=label>
<div class="${properties.kcCodeClipboardCopyClass!}" id="kc-${name}-clipboard">
<div class="${properties.kcCodeClipboardCopyClass!} <#if readonly>${properties.kcFormReadOnlyClass!}</#if>" id="kc-${name}-clipboard">
<div class="${properties.kcCodeClipboardCopyGroupClass!}">
<div class="${properties.kcInputGroup!}">
<div class="${properties.kcInputGroupItemClass!}">
<button
class="${properties.kcFormPasswordVisibilityButtonClass!}"
type="button"
aria-label="${msg("code-clipboard-label")}"
aria-expanded="false"
aria-controls="kc-${name}-content"
data-icon-expanded-class="${properties.kcAngleDownIconClass!}"
data-icon-collapsed-class="${properties.kcAngleRightIconClass!}"
data-expanded-class="${properties.kcExpandedClass!}"
id="kc-${name}-toggle"
>
<i id="kc-${name}-toggle-icon" class="${properties.kcAngleRightIconClass!}" aria-hidden="true"></i>
</button>
</div>
<div class="${properties.kcInputGroupItemClass!} ${properties.kcFill!}">
<span class="${properties.kcInputClass!} <#if readonly>${properties.kcFormReadOnlyClass!}</#if>">
<input
id="${name}"
name="${name}"
value="${value}"
type="text"
<#if readonly>readonly</#if>
aria-label="${ariaLabel}"
/>
</span>
</div>
<div class="${properties.kcInputGroupItemClass!}">
<button
class="${properties.kcFormPasswordVisibilityButtonClass!}"
type="button"
aria-label="${msg("code-copy-label")}"
data-icon-success="${properties.kcCheckIconClass!}"
data-icon-failure="${properties.kcInputErrorIconClass!}"
data-success-label="${msg("code-copy-success")}"
data-failure-label="${msg("code-copy-failure")}"
id="kc-${name}-copy-button"
>
<button
class="${properties.kcFormPasswordVisibilityButtonClass!}"
type="button"
aria-label="${msg("code-clipboard-label")}"
aria-expanded="false"
aria-controls="kc-${name}-content"
data-icon-expanded-class="${properties.kcCodeClipboardCopyExpandedIconClass!properties.kcAngleDownIconClass!}"
data-icon-collapsed-class="${properties.kcCodeClipboardCopyCollapsedIconClass!properties.kcAngleRightIconClass!}"
data-expanded-class="${properties.kcExpandedClass!}"
id="kc-${name}-toggle"
>
<#if properties.kcButtonIconClass?has_content><span class="${properties.kcButtonIconClass}"></#if>
<#if properties.kcCodeClipboardCopyToggleIconClass?has_content><span class="${properties.kcCodeClipboardCopyToggleIconClass}"></#if>
<i id="kc-${name}-toggle-icon" class="${properties.kcCodeClipboardCopyCollapsedIconClass!properties.kcAngleRightIconClass!}" aria-hidden="true"></i>
<#if properties.kcCodeClipboardCopyToggleIconClass?has_content></span></#if>
<#if properties.kcButtonIconClass?has_content></span></#if>
</button>
<span class="${properties.kcInputClass!} <#if readonly>${properties.kcFormReadOnlyClass!}</#if>">
<input
id="${name}"
name="${name}"
value="${value}"
type="text"
<#if readonly>readonly</#if>
aria-label="${ariaLabel}"
/>
</span>
<button
class="${properties.kcFormPasswordVisibilityButtonClass!}"
type="button"
aria-label="${msg("code-copy-label")}"
data-icon-success="${properties.kcCheckIconClass!}"
data-icon-failure="${properties.kcInputErrorIconClass!}"
data-success-label="${msg("code-copy-success")}"
data-failure-label="${msg("code-copy-failure")}"
id="kc-${name}-copy-button"
>
<#if properties.kcButtonIconClass?has_content><span class="${properties.kcButtonIconClass}"></#if>
<i id="kc-${name}-copy-icon" class="${properties.kcCopyIconClass!}" aria-hidden="true"></i>
</button>
</div>
</div>
<#if properties.kcButtonIconClass?has_content></span></#if>
</button>
</div>
<div class="${properties.kcCodeClipboardCopyContentClass!}" id="kc-${name}-content" hidden>
<pre><code aria-label="${ariaLabel}">${value}</code></pre>
Expand Down
Loading
Loading
0