KEYCLOAK-12799 Missing Cancel button on The WebAuthn setup screen when using AIA
This commit is contained in:
parent
fae333750a
commit
a1bbab9eb2
8 changed files with 334 additions and 112 deletions
|
@ -41,6 +41,8 @@ import org.keycloak.credential.WebAuthnCredentialProviderFactory;
|
||||||
import org.keycloak.crypto.Algorithm;
|
import org.keycloak.crypto.Algorithm;
|
||||||
import org.keycloak.events.Details;
|
import org.keycloak.events.Details;
|
||||||
import org.keycloak.events.Errors;
|
import org.keycloak.events.Errors;
|
||||||
|
import org.keycloak.forms.login.LoginFormsProvider;
|
||||||
|
import org.keycloak.models.Constants;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.UserModel;
|
import org.keycloak.models.UserModel;
|
||||||
import org.keycloak.models.WebAuthnPolicy;
|
import org.keycloak.models.WebAuthnPolicy;
|
||||||
|
@ -135,6 +137,8 @@ public class WebAuthnRegister implements RequiredActionProvider, CredentialRegis
|
||||||
excludeCredentialIds = stringifyExcludeCredentialIds(webAuthnCredentialPubKeyIds);
|
excludeCredentialIds = stringifyExcludeCredentialIds(webAuthnCredentialPubKeyIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String isSetRetry = context.getHttpRequest().getDecodedFormParameters().getFirst(WebAuthnConstants.IS_SET_RETRY);
|
||||||
|
|
||||||
Response form = context.form()
|
Response form = context.form()
|
||||||
.setAttribute(WebAuthnConstants.CHALLENGE, challengeValue)
|
.setAttribute(WebAuthnConstants.CHALLENGE, challengeValue)
|
||||||
.setAttribute(WebAuthnConstants.USER_ID, userId)
|
.setAttribute(WebAuthnConstants.USER_ID, userId)
|
||||||
|
@ -148,6 +152,7 @@ public class WebAuthnRegister implements RequiredActionProvider, CredentialRegis
|
||||||
.setAttribute(WebAuthnConstants.USER_VERIFICATION_REQUIREMENT, userVerificationRequirement)
|
.setAttribute(WebAuthnConstants.USER_VERIFICATION_REQUIREMENT, userVerificationRequirement)
|
||||||
.setAttribute(WebAuthnConstants.CREATE_TIMEOUT, createTimeout)
|
.setAttribute(WebAuthnConstants.CREATE_TIMEOUT, createTimeout)
|
||||||
.setAttribute(WebAuthnConstants.EXCLUDE_CREDENTIAL_IDS, excludeCredentialIds)
|
.setAttribute(WebAuthnConstants.EXCLUDE_CREDENTIAL_IDS, excludeCredentialIds)
|
||||||
|
.setAttribute(WebAuthnConstants.IS_SET_RETRY, isSetRetry)
|
||||||
.createForm("webauthn-register.ftl");
|
.createForm("webauthn-register.ftl");
|
||||||
context.challenge(form);
|
context.challenge(form);
|
||||||
}
|
}
|
||||||
|
|
|
@ -195,10 +195,6 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
|
||||||
attributes.put("statusCode", status.getStatusCode());
|
attributes.put("statusCode", status.getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authenticationSession != null && authenticationSession.getClientNote(Constants.KC_ACTION_EXECUTING) != null) {
|
|
||||||
attributes.put("isAppInitiatedAction", true);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (page) {
|
switch (page) {
|
||||||
case LOGIN_CONFIG_TOTP:
|
case LOGIN_CONFIG_TOTP:
|
||||||
attributes.put("totp", new TotpBean(session, realm, user, uriInfo.getRequestUriBuilder()));
|
attributes.put("totp", new TotpBean(session, realm, user, uriInfo.getRequestUriBuilder()));
|
||||||
|
@ -447,6 +443,10 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
|
||||||
if (realm != null && user != null && session != null) {
|
if (realm != null && user != null && session != null) {
|
||||||
attributes.put("authenticatorConfigured", new AuthenticatorConfiguredMethod(realm, user, session));
|
attributes.put("authenticatorConfigured", new AuthenticatorConfiguredMethod(realm, user, session));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authenticationSession != null && authenticationSession.getClientNote(Constants.KC_ACTION_EXECUTING) != null) {
|
||||||
|
attributes.put("isAppInitiatedAction", true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package org.keycloak.testsuite.pages.webauthn;
|
package org.keycloak.testsuite.pages.webauthn;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
import org.keycloak.testsuite.pages.LanguageComboboxAwarePage;
|
import org.keycloak.testsuite.pages.LanguageComboboxAwarePage;
|
||||||
import org.openqa.selenium.By;
|
import org.openqa.selenium.By;
|
||||||
import org.openqa.selenium.NoSuchElementException;
|
import org.openqa.selenium.NoSuchElementException;
|
||||||
|
@ -14,10 +15,22 @@ public class WebAuthnErrorPage extends LanguageComboboxAwarePage {
|
||||||
@FindBy(id = "kc-try-again")
|
@FindBy(id = "kc-try-again")
|
||||||
private WebElement tryAgainButton;
|
private WebElement tryAgainButton;
|
||||||
|
|
||||||
|
// Available only with AIA
|
||||||
|
@FindBy(id = "cancelWebAuthnAIA")
|
||||||
|
private WebElement cancelRegistrationAIA;
|
||||||
|
|
||||||
public void clickTryAgain() {
|
public void clickTryAgain() {
|
||||||
tryAgainButton.click();
|
tryAgainButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void clickCancelRegistrationAIA() {
|
||||||
|
try {
|
||||||
|
cancelRegistrationAIA.click();
|
||||||
|
} catch (NoSuchElementException e) {
|
||||||
|
Assert.fail("It only works with AIA");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean isCurrent() {
|
public boolean isCurrent() {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -20,6 +20,9 @@ package org.keycloak.testsuite.pages.webauthn;
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.keycloak.testsuite.pages.AbstractPage;
|
import org.keycloak.testsuite.pages.AbstractPage;
|
||||||
import org.openqa.selenium.Alert;
|
import org.openqa.selenium.Alert;
|
||||||
|
import org.openqa.selenium.NoSuchElementException;
|
||||||
|
import org.openqa.selenium.WebElement;
|
||||||
|
import org.openqa.selenium.support.FindBy;
|
||||||
import org.openqa.selenium.support.ui.ExpectedConditions;
|
import org.openqa.selenium.support.ui.ExpectedConditions;
|
||||||
import org.openqa.selenium.support.ui.WebDriverWait;
|
import org.openqa.selenium.support.ui.WebDriverWait;
|
||||||
|
|
||||||
|
@ -31,6 +34,23 @@ import org.openqa.selenium.support.ui.WebDriverWait;
|
||||||
*/
|
*/
|
||||||
public class WebAuthnRegisterPage extends AbstractPage {
|
public class WebAuthnRegisterPage extends AbstractPage {
|
||||||
|
|
||||||
|
// Available only with AIA
|
||||||
|
@FindBy(id = "registerWebAuthnAIA")
|
||||||
|
private WebElement registerAIAButton;
|
||||||
|
|
||||||
|
// Available only with AIA
|
||||||
|
@FindBy(id = "cancelWebAuthnAIA")
|
||||||
|
private WebElement cancelAIAButton;
|
||||||
|
|
||||||
|
public void confirmAIA() {
|
||||||
|
Assert.assertTrue("It only works with AIA", isAIA());
|
||||||
|
registerAIAButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void cancelAIA() {
|
||||||
|
Assert.assertTrue("It only works with AIA", isAIA());
|
||||||
|
cancelAIAButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
public void registerWebAuthnCredential(String authenticatorLabel) {
|
public void registerWebAuthnCredential(String authenticatorLabel) {
|
||||||
// label edit after registering authenicator by .create()
|
// label edit after registering authenicator by .create()
|
||||||
|
@ -43,8 +63,20 @@ public class WebAuthnRegisterPage extends AbstractPage {
|
||||||
promptDialog.accept();
|
promptDialog.accept();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isAIA() {
|
||||||
|
try {
|
||||||
|
registerAIAButton.getText();
|
||||||
|
cancelAIAButton.getText();
|
||||||
|
return true;
|
||||||
|
} catch (NoSuchElementException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isCurrent() {
|
public boolean isCurrent() {
|
||||||
|
if (isAIA()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
// Cant verify the page in case that prompt is shown. Prompt is shown immediately when WebAuthnRegisterPage is displayed
|
// Cant verify the page in case that prompt is shown. Prompt is shown immediately when WebAuthnRegisterPage is displayed
|
||||||
throw new UnsupportedOperationException();
|
throw new UnsupportedOperationException();
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,131 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||||
|
* and other contributors as indicated by the @author tags.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
package org.keycloak.testsuite.webauthn;
|
||||||
|
|
||||||
|
import org.jboss.arquillian.graphene.page.Page;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.keycloak.authentication.authenticators.browser.PasswordFormFactory;
|
||||||
|
import org.keycloak.authentication.authenticators.browser.UsernameFormFactory;
|
||||||
|
import org.keycloak.authentication.authenticators.browser.WebAuthnAuthenticatorFactory;
|
||||||
|
import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory;
|
||||||
|
import org.keycloak.events.Details;
|
||||||
|
import org.keycloak.representations.idm.RealmRepresentation;
|
||||||
|
import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
|
||||||
|
import org.keycloak.testsuite.WebAuthnAssume;
|
||||||
|
import org.keycloak.testsuite.actions.AbstractAppInitiatedActionTest;
|
||||||
|
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
|
||||||
|
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
|
||||||
|
import org.keycloak.testsuite.pages.LoginUsernameOnlyPage;
|
||||||
|
import org.keycloak.testsuite.pages.PasswordPage;
|
||||||
|
import org.keycloak.testsuite.pages.webauthn.WebAuthnRegisterPage;
|
||||||
|
import org.keycloak.testsuite.util.FlowUtil;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.keycloak.common.Profile.Feature.WEB_AUTHN;
|
||||||
|
import static org.keycloak.models.AuthenticationExecutionModel.Requirement.ALTERNATIVE;
|
||||||
|
import static org.keycloak.models.AuthenticationExecutionModel.Requirement.REQUIRED;
|
||||||
|
import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author <a href="mailto:mabartos@redhat.com">Martin Bartos</a>
|
||||||
|
*/
|
||||||
|
@EnableFeature(value = WEB_AUTHN, skipRestart = true, onlyForProduct = true)
|
||||||
|
@AuthServerContainerExclude(REMOTE)
|
||||||
|
public class AppInitiatedActionWebAuthnTest extends AbstractAppInitiatedActionTest {
|
||||||
|
|
||||||
|
@Page
|
||||||
|
LoginUsernameOnlyPage usernamePage;
|
||||||
|
|
||||||
|
@Page
|
||||||
|
PasswordPage passwordPage;
|
||||||
|
|
||||||
|
@Page
|
||||||
|
WebAuthnRegisterPage registerPage;
|
||||||
|
|
||||||
|
public AppInitiatedActionWebAuthnTest() {
|
||||||
|
super(WebAuthnRegisterFactory.PROVIDER_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean isImportAfterEachMethod() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configureTestRealm(RealmRepresentation testRealm) {
|
||||||
|
RequiredActionProviderRepresentation action = new RequiredActionProviderRepresentation();
|
||||||
|
action.setAlias(WebAuthnRegisterFactory.PROVIDER_ID);
|
||||||
|
action.setProviderId(WebAuthnRegisterFactory.PROVIDER_ID);
|
||||||
|
action.setEnabled(true);
|
||||||
|
action.setDefaultAction(true);
|
||||||
|
action.setPriority(10);
|
||||||
|
|
||||||
|
List<RequiredActionProviderRepresentation> actions = new ArrayList<>();
|
||||||
|
actions.add(action);
|
||||||
|
testRealm.setRequiredActions(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void setUpWebAuthnFlow() {
|
||||||
|
final String newFlowAlias = "browserWebAuthnAIA";
|
||||||
|
testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(newFlowAlias));
|
||||||
|
testingClient.server("test").run(session -> {
|
||||||
|
FlowUtil.inCurrentRealm(session)
|
||||||
|
.selectFlow(newFlowAlias)
|
||||||
|
.inForms(forms -> forms
|
||||||
|
.clear()
|
||||||
|
.addAuthenticatorExecution(REQUIRED, UsernameFormFactory.PROVIDER_ID)
|
||||||
|
.addSubFlowExecution(REQUIRED, subFlow -> subFlow
|
||||||
|
.addAuthenticatorExecution(ALTERNATIVE, PasswordFormFactory.PROVIDER_ID)
|
||||||
|
.addAuthenticatorExecution(ALTERNATIVE, WebAuthnAuthenticatorFactory.PROVIDER_ID)))
|
||||||
|
.defineAsBrowserFlow();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void verifyEnvironment() {
|
||||||
|
WebAuthnAssume.assumeChrome();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void cancelSetupWebAuthn() {
|
||||||
|
loginUser();
|
||||||
|
|
||||||
|
doAIA();
|
||||||
|
|
||||||
|
registerPage.assertCurrent();
|
||||||
|
registerPage.cancelAIA();
|
||||||
|
|
||||||
|
assertKcActionStatus("cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loginUser() {
|
||||||
|
usernamePage.open();
|
||||||
|
usernamePage.assertCurrent();
|
||||||
|
usernamePage.login("test-user@localhost");
|
||||||
|
|
||||||
|
passwordPage.assertCurrent();
|
||||||
|
passwordPage.login("password");
|
||||||
|
|
||||||
|
events.expectLogin()
|
||||||
|
.detail(Details.USERNAME, "test-user@localhost")
|
||||||
|
.assertEvent();
|
||||||
|
}
|
||||||
|
}
|
|
@ -37,10 +37,19 @@
|
||||||
</table>
|
</table>
|
||||||
</#if>
|
</#if>
|
||||||
|
|
||||||
<div id="kc-error-message">
|
|
||||||
<input tabindex="4" onclick="refreshPage()" type="button"
|
<input tabindex="4" onclick="refreshPage()" type="button"
|
||||||
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
|
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
|
||||||
name="try-again" id="kc-try-again" value="${kcSanitize(msg("doTryAgain"))?no_esc}"/>
|
name="try-again" id="kc-try-again" value="${kcSanitize(msg("doTryAgain"))?no_esc}"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
|
<#if isAppInitiatedAction??>
|
||||||
|
<form action="${url.loginAction}" class="${properties.kcFormClass!}" id="kc-webauthn-settings-form" method="post">
|
||||||
|
<button type="submit"
|
||||||
|
class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
|
||||||
|
id="cancelWebAuthnAIA" name="cancel-aia" value="true"/>${msg("doCancel")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</#if>
|
||||||
|
|
||||||
</#if>
|
</#if>
|
||||||
</@layout.registrationLayout>
|
</@layout.registrationLayout>
|
|
@ -16,9 +16,12 @@
|
||||||
<input type="hidden" id="error" name="error"/>
|
<input type="hidden" id="error" name="error"/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<script type="text/javascript" src="${url.resourcesPath}/node_modules/jquery/dist/jquery.min.js"></script>
|
<script type="text/javascript" src="${url.resourcesPath}/node_modules/jquery/dist/jquery.min.js"></script>
|
||||||
<script type="text/javascript" src="${url.resourcesPath}/js/base64url.js"></script>
|
<script type="text/javascript" src="${url.resourcesPath}/js/base64url.js"></script>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
|
||||||
|
function registerSecurityKey() {
|
||||||
// mandatory parameters
|
// mandatory parameters
|
||||||
let challenge = "${challenge}";
|
let challenge = "${challenge}";
|
||||||
let userid = "${userid}";
|
let userid = "${userid}";
|
||||||
|
@ -39,7 +42,7 @@
|
||||||
displayName: username
|
displayName: username
|
||||||
},
|
},
|
||||||
pubKeyCredParams: pubKeyCredParams,
|
pubKeyCredParams: pubKeyCredParams,
|
||||||
}
|
};
|
||||||
|
|
||||||
// optional parameters
|
// optional parameters
|
||||||
let rpId = "${rpId}";
|
let rpId = "${rpId}";
|
||||||
|
@ -105,6 +108,7 @@
|
||||||
$("#register").submit();
|
$("#register").submit();
|
||||||
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getPubKeyCredParams(signatureAlgorithms) {
|
function getPubKeyCredParams(signatureAlgorithms) {
|
||||||
let pubKeyCredParams = [];
|
let pubKeyCredParams = [];
|
||||||
|
@ -115,7 +119,10 @@
|
||||||
let signatureAlgorithmsList = signatureAlgorithms.split(',');
|
let signatureAlgorithmsList = signatureAlgorithms.split(',');
|
||||||
|
|
||||||
for (let i = 0; i < signatureAlgorithmsList.length; i++) {
|
for (let i = 0; i < signatureAlgorithmsList.length; i++) {
|
||||||
pubKeyCredParams.push({type: "public-key", alg: signatureAlgorithmsList[i]});
|
pubKeyCredParams.push({
|
||||||
|
type: "public-key",
|
||||||
|
alg: signatureAlgorithmsList[i]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return pubKeyCredParams;
|
return pubKeyCredParams;
|
||||||
}
|
}
|
||||||
|
@ -127,12 +134,33 @@
|
||||||
let excludeCredentialIdsList = excludeCredentialIds.split(',');
|
let excludeCredentialIdsList = excludeCredentialIds.split(',');
|
||||||
|
|
||||||
for (let i = 0; i < excludeCredentialIdsList.length; i++) {
|
for (let i = 0; i < excludeCredentialIdsList.length; i++) {
|
||||||
excludeCredentials.push({type: "public-key", id: base64url.decode(excludeCredentialIdsList[i], { loose: true })});
|
excludeCredentials.push({
|
||||||
|
type: "public-key",
|
||||||
|
id: base64url.decode(excludeCredentialIdsList[i],
|
||||||
|
{loose: true})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return excludeCredentials;
|
return excludeCredentials;
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<#if !isSetRetry?has_content && isAppInitiatedAction?has_content>
|
||||||
|
<input type="submit"
|
||||||
|
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
|
||||||
|
id="registerWebAuthnAIA" value="${msg("doRegister")}" onclick="registerSecurityKey()"
|
||||||
|
/>
|
||||||
|
<form action="${url.loginAction}" class="${properties.kcFormClass!}" id="kc-webauthn-settings-form"
|
||||||
|
method="post">
|
||||||
|
<button type="submit"
|
||||||
|
class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
|
||||||
|
id="cancelWebAuthnAIA" name="cancel-aia" value="true"/>${msg("doCancel")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<#else>
|
||||||
|
<script>
|
||||||
|
registerSecurityKey();
|
||||||
|
</script>
|
||||||
|
</#if>
|
||||||
|
|
||||||
</#if>
|
</#if>
|
||||||
</@layout.registrationLayout>
|
</@layout.registrationLayout>
|
|
@ -127,6 +127,10 @@ div.kc-logo-text span {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#kc-webauthn-settings-form{
|
||||||
|
padding-top:8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* #kc-content-wrapper {
|
/* #kc-content-wrapper {
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
} */
|
} */
|
||||||
|
|
Loading…
Reference in a new issue