diff --git a/plugins/user-authenticators/ldap/src/main/java/org/apache/cloudstack/api/command/LdapTestConfigurationCmd.java b/plugins/user-authenticators/ldap/src/main/java/org/apache/cloudstack/api/command/LdapTestConfigurationCmd.java new file mode 100644 index 000000000000..2c67c2052437 --- /dev/null +++ b/plugins/user-authenticators/ldap/src/main/java/org/apache/cloudstack/api/command/LdapTestConfigurationCmd.java @@ -0,0 +1,82 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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.apache.cloudstack.api.command; + +import javax.inject.Inject; + +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.DomainResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.ldap.LdapManager; + +import com.cloud.user.Account; + +@APICommand(name = "ldapTestConfiguration", description = "Tests connectivity to an LDAP server without saving a configuration", responseObject = SuccessResponse.class, + since = "24.0", requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) +public class LdapTestConfigurationCmd extends BaseCmd { + + @Inject + private LdapManager ldapManager; + + @Parameter(name = ApiConstants.HOST_NAME, type = CommandType.STRING, required = true, description = "Hostname") + private String hostname; + + @Parameter(name = ApiConstants.PORT, type = CommandType.INTEGER, description = "Port") + private int port; + + @Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, entityType = DomainResponse.class, description = "Linked Domain") + private Long domainId; + + public LdapTestConfigurationCmd() { + super(); + } + + public LdapTestConfigurationCmd(final LdapManager ldapManager) { + super(); + this.ldapManager = ldapManager; + } + + public String getHostname() { + return hostname; + } + + public int getPort() { + return port; + } + + public Long getDomainId() { + return domainId; + } + + @Override + public void execute() throws ServerApiException { + // a failing bind throws InvalidParameterValueException, which is reported to the caller as an API error + ldapManager.testConnection(this); + SuccessResponse response = new SuccessResponse(getCommandName()); + response.setDisplayText("Successfully connected to the LDAP server"); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } +} diff --git a/plugins/user-authenticators/ldap/src/main/java/org/apache/cloudstack/ldap/LdapManager.java b/plugins/user-authenticators/ldap/src/main/java/org/apache/cloudstack/ldap/LdapManager.java index 7e561ccf754a..d432340df61d 100644 --- a/plugins/user-authenticators/ldap/src/main/java/org/apache/cloudstack/ldap/LdapManager.java +++ b/plugins/user-authenticators/ldap/src/main/java/org/apache/cloudstack/ldap/LdapManager.java @@ -21,6 +21,7 @@ import org.apache.cloudstack.api.command.LdapAddConfigurationCmd; import org.apache.cloudstack.api.command.LdapDeleteConfigurationCmd; import org.apache.cloudstack.api.command.LdapListConfigurationCmd; +import org.apache.cloudstack.api.command.LdapTestConfigurationCmd; import org.apache.cloudstack.api.command.LinkAccountToLdapCmd; import org.apache.cloudstack.api.command.LinkDomainToLdapCmd; import org.apache.cloudstack.api.command.UnlinkDomainFromLdapCmd; @@ -41,6 +42,8 @@ enum LinkType { GROUP, OU } LdapConfigurationResponse addConfiguration(String hostname, int port, Long domainId) throws InvalidParameterValueException; + void testConnection(LdapTestConfigurationCmd cmd) throws InvalidParameterValueException; + boolean canAuthenticate(String principal, String password, final Long domainId); LdapConfigurationResponse createLdapConfigurationResponse(LdapConfigurationVO configuration); diff --git a/plugins/user-authenticators/ldap/src/main/java/org/apache/cloudstack/ldap/LdapManagerImpl.java b/plugins/user-authenticators/ldap/src/main/java/org/apache/cloudstack/ldap/LdapManagerImpl.java index 69bb116252ad..e0ebbad30dbe 100644 --- a/plugins/user-authenticators/ldap/src/main/java/org/apache/cloudstack/ldap/LdapManagerImpl.java +++ b/plugins/user-authenticators/ldap/src/main/java/org/apache/cloudstack/ldap/LdapManagerImpl.java @@ -36,6 +36,7 @@ import org.apache.cloudstack.api.command.LdapImportUsersCmd; import org.apache.cloudstack.api.command.LdapListConfigurationCmd; import org.apache.cloudstack.api.command.LdapListUsersCmd; +import org.apache.cloudstack.api.command.LdapTestConfigurationCmd; import org.apache.cloudstack.api.command.LdapUserSearchCmd; import org.apache.cloudstack.api.command.LinkAccountToLdapCmd; import org.apache.cloudstack.api.command.LinkDomainToLdapCmd; @@ -175,30 +176,47 @@ private LdapConfigurationResponse addConfigurationInternal(final String hostname // hostname:port is unique for domain binding LdapConfigurationVO configuration = _ldapConfigurationDao.find(hostname, port, domainId); if (configuration == null) { - LdapContext context = null; - try { - final String providerUrl = "ldap://" + hostname + ":" + port; - context = _ldapContextFactory.createBindContext(providerUrl,domainId); - configuration = new LdapConfigurationVO(hostname, port, domainId); - _ldapConfigurationDao.persist(configuration); - logger.info("Added a new LDAP server with URL: {}{}", providerUrl, domainId == null ? "" : " for domain " + domainId); - return createLdapConfigurationResponse(configuration); - } catch (NamingException | IOException e) { - logger.debug("NamingException while doing an LDAP bind", e); - throw new InvalidParameterValueException("Unable to bind to the given LDAP server"); - } catch (RuntimeException e) { - if (e.getMessage().contains("Invalid truststore")) { - throw new InvalidParameterValueException("Invalid truststore or truststore password"); - } - throw e; - } finally { - closeContext(context); - } + testBind(hostname, port, domainId); + configuration = new LdapConfigurationVO(hostname, port, domainId); + _ldapConfigurationDao.persist(configuration); + logger.info("Added a new LDAP server with URL: ldap://{}:{}{}", hostname, port, domainId == null ? "" : " for domain " + domainId); + return createLdapConfigurationResponse(configuration); } else { throw new InvalidParameterValueException("Duplicate configuration"); } } + @Override + public void testConnection(LdapTestConfigurationCmd cmd) throws InvalidParameterValueException { + int port = cmd.getPort(); + if (port <= 0) { + port = 389; + } + testBind(cmd.getHostname(), port, cmd.getDomainId()); + } + + /** + * Binds to the given LDAP server without persisting a configuration, so both adding a new + * configuration and {@link #testConnection} can share the same connectivity check. + */ + private void testBind(final String hostname, final int port, final Long domainId) throws InvalidParameterValueException { + LdapContext context = null; + try { + final String providerUrl = "ldap://" + hostname + ":" + port; + context = _ldapContextFactory.createBindContext(providerUrl, domainId); + } catch (NamingException | IOException e) { + logger.debug("NamingException while doing an LDAP bind", e); + throw new InvalidParameterValueException("Unable to bind to the given LDAP server"); + } catch (RuntimeException e) { + if (StringUtils.contains(e.getMessage(), "Invalid truststore")) { + throw new InvalidParameterValueException("Invalid truststore or truststore password"); + } + throw e; + } finally { + closeContext(context); + } + } + /** * TODO decide if the principal is good enough to get the domain id or we need to add it as parameter * @param principal ldap user @@ -302,6 +320,7 @@ public List> getCommands() { cmdList.add(LdapUserSearchCmd.class); cmdList.add(LdapListUsersCmd.class); cmdList.add(LdapAddConfigurationCmd.class); + cmdList.add(LdapTestConfigurationCmd.class); cmdList.add(LdapDeleteConfigurationCmd.class); cmdList.add(LdapListConfigurationCmd.class); cmdList.add(LdapCreateAccountCmd.class); diff --git a/plugins/user-authenticators/ldap/src/test/java/org/apache/cloudstack/api/command/LdapTestConfigurationCmdTest.java b/plugins/user-authenticators/ldap/src/test/java/org/apache/cloudstack/api/command/LdapTestConfigurationCmdTest.java new file mode 100644 index 000000000000..16cbd6b0e7a8 --- /dev/null +++ b/plugins/user-authenticators/ldap/src/test/java/org/apache/cloudstack/api/command/LdapTestConfigurationCmdTest.java @@ -0,0 +1,85 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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.apache.cloudstack.api.command; + +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.user.Account; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.ldap.LdapManager; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +/** + * Tests {@link LdapTestConfigurationCmd}: a successful bind yields a success response, a failed + * bind propagates as an error rather than a response reporting success=false. + */ +@RunWith(MockitoJUnitRunner.class) +public class LdapTestConfigurationCmdTest { + + @Mock + private LdapManager ldapManager; + + private LdapTestConfigurationCmd cmd; + + @Before + public void setUp() { + cmd = new LdapTestConfigurationCmd(ldapManager); + ReflectionTestUtils.setField(cmd, "hostname", "ldap.example.com"); + ReflectionTestUtils.setField(cmd, "port", 389); + ReflectionTestUtils.setField(cmd, "domainId", 1L); + } + + @Test + public void executeReturnsSuccessResponseWhenBindSucceeds() { + cmd.execute(); + + verify(ldapManager).testConnection(cmd); + SuccessResponse response = (SuccessResponse) cmd.getResponseObject(); + assertTrue(response.getSuccess()); + assertEquals("ldaptestconfigurationresponse", response.getResponseName()); + assertEquals("Successfully connected to the LDAP server", response.getDisplayText()); + } + + @Test + public void executePropagatesBindFailure() { + doThrow(new InvalidParameterValueException("Unable to bind to the given LDAP server")).when(ldapManager).testConnection(cmd); + + InvalidParameterValueException e = assertThrows(InvalidParameterValueException.class, () -> cmd.execute()); + + assertEquals("Unable to bind to the given LDAP server", e.getMessage()); + assertNull(cmd.getResponseObject()); + } + + @Test + public void gettersExposeParameters() { + assertEquals("ldap.example.com", cmd.getHostname()); + assertEquals(389, cmd.getPort()); + assertEquals(Long.valueOf(1L), cmd.getDomainId()); + assertEquals(Account.ACCOUNT_ID_SYSTEM, cmd.getEntityOwnerId()); + } +} diff --git a/plugins/user-authenticators/ldap/src/test/java/org/apache/cloudstack/ldap/LdapManagerImplTest.java b/plugins/user-authenticators/ldap/src/test/java/org/apache/cloudstack/ldap/LdapManagerImplTest.java index 917c29b81aeb..5d2333a96392 100644 --- a/plugins/user-authenticators/ldap/src/test/java/org/apache/cloudstack/ldap/LdapManagerImplTest.java +++ b/plugins/user-authenticators/ldap/src/test/java/org/apache/cloudstack/ldap/LdapManagerImplTest.java @@ -18,13 +18,17 @@ import com.cloud.domain.DomainVO; import com.cloud.domain.dao.DomainDao; +import com.cloud.exception.InvalidParameterValueException; import com.cloud.user.Account; +import com.cloud.user.AccountManager; import com.cloud.user.AccountVO; import com.cloud.user.dao.AccountDao; import com.cloud.utils.exception.CloudRuntimeException; import org.apache.cloudstack.acl.RoleService; +import org.apache.cloudstack.api.command.LdapTestConfigurationCmd; import org.apache.cloudstack.api.command.LinkAccountToLdapCmd; import org.apache.cloudstack.api.response.LinkAccountToLdapResponse; +import org.apache.cloudstack.ldap.dao.LdapConfigurationDao; import org.apache.cloudstack.ldap.dao.LdapTrustMapDao; import org.junit.After; import org.junit.Before; @@ -36,12 +40,16 @@ import org.mockito.junit.MockitoJUnitRunner; import org.springframework.test.util.ReflectionTestUtils; +import javax.naming.NamingException; import java.util.Date; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -51,6 +59,11 @@ * Tests {@link LdapManagerImpl#linkAccountToLdap}: re-linking an account replaces its own * mapping, refuses a group already claimed by another live account, and leaves no mapping * persisted if clearing the old one fails. + *

+ * Tests {@link LdapManagerImpl#testConnection} and {@link LdapManagerImpl#addConfiguration}: + * testing a connection binds to the LDAP server (defaulting the port to 389 when omitted) but + * never persists a configuration, whether the bind succeeds or fails; adding a configuration + * still binds before persisting, and does not persist when the bind fails. */ @RunWith(MockitoJUnitRunner.class) public class LdapManagerImplTest { @@ -75,21 +88,34 @@ public class LdapManagerImplTest { @Mock private RoleService roleServiceMock; + @Mock + private LdapConfigurationDao ldapConfigurationDaoMock; + + @Mock + private LdapContextFactory ldapContextFactoryMock; + + @Mock + private AccountManager accountManagerMock; + @Before public void setup() { ldapConfigurationMockedStatic = Mockito.mockStatic(LdapConfiguration.class, Mockito.CALLS_REAL_METHODS); - when(LdapConfiguration.getBaseDn(DOMAIN_ID)).thenReturn("dc=my,dc=domain,dc=com"); + // lenient: the connection tests do not use the link-account stubs + lenient().when(LdapConfiguration.getBaseDn(DOMAIN_ID)).thenReturn("dc=my,dc=domain,dc=com"); ldapManager = new LdapManagerImpl(); ldapManager._ldapTrustMapDao = ldapTrustMapDaoMock; ReflectionTestUtils.setField(ldapManager, "domainDao", domainDaoMock); ReflectionTestUtils.setField(ldapManager, "accountDao", accountDaoMock); - when(domainDaoMock.findById(DOMAIN_ID)).thenReturn(new DomainVO()); + ReflectionTestUtils.setField(ldapManager, "_ldapConfigurationDao", ldapConfigurationDaoMock); + ReflectionTestUtils.setField(ldapManager, "_ldapContextFactory", ldapContextFactoryMock); + ReflectionTestUtils.setField(ldapManager, "accountManager", accountManagerMock); + lenient().when(domainDaoMock.findById(DOMAIN_ID)).thenReturn(new DomainVO()); AccountVO existingAccount = new AccountVO("jdoe", DOMAIN_ID, null, Account.Type.NORMAL, null, "acct-uuid"); ReflectionTestUtils.setField(existingAccount, "id", ACCOUNT_ID); - when(accountDaoMock.findActiveAccount("jdoe", DOMAIN_ID)).thenReturn(existingAccount); - when(ldapTrustMapDaoMock.persist(any())).thenAnswer(invocation -> invocation.getArgument(0)); + lenient().when(accountDaoMock.findActiveAccount("jdoe", DOMAIN_ID)).thenReturn(existingAccount); + lenient().when(ldapTrustMapDaoMock.persist(any())).thenAnswer(invocation -> invocation.getArgument(0)); } @After @@ -172,6 +198,77 @@ public void relinkingAccountDoesNotPersistWhenClearingOldMappingFails() { verify(ldapTrustMapDaoMock, never()).persist(any()); } + @Test + public void testConnectionDoesNotPersistOnSuccess() throws Exception { + LdapTestConfigurationCmd cmd = buildTestCmd("ldap.example.com", 389); + + ldapManager.testConnection(cmd); + + verify(ldapContextFactoryMock).createBindContext("ldap://ldap.example.com:389", DOMAIN_ID); + verify(ldapConfigurationDaoMock, never()).persist(any()); + } + + @Test + public void testConnectionDefaultsPortWhenNotGiven() throws Exception { + LdapTestConfigurationCmd cmd = buildTestCmd("ldap.example.com", 0); + + ldapManager.testConnection(cmd); + + verify(ldapContextFactoryMock).createBindContext("ldap://ldap.example.com:389", DOMAIN_ID); + } + + @Test + public void testConnectionThrowsOnBindFailure() throws Exception { + LdapTestConfigurationCmd cmd = buildTestCmd("ldap.example.com", 389); + doThrow(new NamingException("bind failed")).when(ldapContextFactoryMock).createBindContext(any(), anyLong()); + + assertThrows(InvalidParameterValueException.class, () -> ldapManager.testConnection(cmd)); + + verify(ldapConfigurationDaoMock, never()).persist(any()); + } + + @Test + public void testConnectionReportsInvalidTruststore() throws Exception { + LdapTestConfigurationCmd cmd = buildTestCmd("ldap.example.com", 636); + doThrow(new RuntimeException("Invalid truststore")).when(ldapContextFactoryMock).createBindContext(any(), anyLong()); + + InvalidParameterValueException e = assertThrows(InvalidParameterValueException.class, () -> ldapManager.testConnection(cmd)); + + assertEquals("Invalid truststore or truststore password", e.getMessage()); + } + + @Test + public void testConnectionRethrowsRuntimeExceptionWithoutMessage() throws Exception { + LdapTestConfigurationCmd cmd = buildTestCmd("ldap.example.com", 389); + IllegalStateException cause = new IllegalStateException(); + doThrow(cause).when(ldapContextFactoryMock).createBindContext(any(), anyLong()); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> ldapManager.testConnection(cmd)); + + assertSame(cause, e); + } + + @Test + public void addConfigurationStillBindsBeforePersisting() throws Exception { + when(ldapConfigurationDaoMock.find("ldap.example.com", 389, DOMAIN_ID)).thenReturn(null); + when(ldapConfigurationDaoMock.persist(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + ldapManager.addConfiguration("ldap.example.com", 389, DOMAIN_ID); + + verify(ldapContextFactoryMock).createBindContext("ldap://ldap.example.com:389", DOMAIN_ID); + verify(ldapConfigurationDaoMock).persist(any()); + } + + @Test + public void addConfigurationDoesNotPersistOnBindFailure() throws Exception { + when(ldapConfigurationDaoMock.find("ldap.example.com", 389, DOMAIN_ID)).thenReturn(null); + doThrow(new NamingException("bind failed")).when(ldapContextFactoryMock).createBindContext(any(), anyLong()); + + assertThrows(InvalidParameterValueException.class, () -> ldapManager.addConfiguration("ldap.example.com", 389, DOMAIN_ID)); + + verify(ldapConfigurationDaoMock, never()).persist(any()); + } + private LinkAccountToLdapCmd buildCmd(String ldapDomain) { LinkAccountToLdapCmd cmd = new LinkAccountToLdapCmd(); cmd.roleService = roleServiceMock; @@ -182,4 +279,12 @@ private LinkAccountToLdapCmd buildCmd(String ldapDomain) { ReflectionTestUtils.setField(cmd, "accountType", Account.Type.NORMAL.ordinal()); return cmd; } + + private LdapTestConfigurationCmd buildTestCmd(String hostname, int port) { + LdapTestConfigurationCmd cmd = new LdapTestConfigurationCmd(); + ReflectionTestUtils.setField(cmd, "hostname", hostname); + ReflectionTestUtils.setField(cmd, "port", port); + ReflectionTestUtils.setField(cmd, "domainId", DOMAIN_ID); + return cmd; + } } diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 0acafc3b8015..99b7798063a0 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -2746,6 +2746,7 @@ "label.tenantname": "Netris Tenant", "label.term.type": "Term type", "label.test": "Test", +"label.test.ldap.configuration": "Test LDAP Connection", "label.test.webhook.delivery": "Test Webhook Delivery", "label.tftpdir": "TFTP root directory", "label.theme.alert": "The setting is only visible to the current browser. To apply the setting, please download the JSON file and replace its content in the `theme` section of the `config.json` file under the path: `/public/config.json`", @@ -4137,6 +4138,7 @@ "message.success.add.dns.server": "Successfully added DNS server", "message.success.create.dns.zone": "Successfully created DNS zone", "message.success.create.dns.record": "Successfully created DNS record", +"message.success.test.ldap.configuration": "Successfully connected to the LDAP server.", "message.success.update.dns.server": "Successfully updated DNS server", "message.success.update.dns.zone": "Successfully updated DNS zone", "message.success.delete.dns.record": "Successfully deleted DNS record", diff --git a/ui/src/config/section/config.js b/ui/src/config/section/config.js index a2c12ce236ac..2777a4402f46 100644 --- a/ui/src/config/section/config.js +++ b/ui/src/config/section/config.js @@ -17,6 +17,16 @@ import { shallowRef, defineAsyncComponent } from 'vue' +// shared by the list view action (enter a server to test) and the data view action (test the selected configuration) +const testLdapConfigurationAction = { + api: 'ldapTestConfiguration', + icon: 'ExperimentOutlined', + label: 'label.test.ldap.configuration', + successMessage: 'message.success.test.ldap.configuration', + docHelp: 'adminguide/accounts.html#using-an-ldap-server-for-user-authentication', + args: ['hostname', 'port', 'domainid'] +} + export default { name: 'config', title: 'label.configuration', @@ -52,6 +62,25 @@ export default { 'hostname', 'port', 'domainid' ] }, + { + ...testLdapConfigurationAction, + listView: true + }, + { + ...testLdapConfigurationAction, + dataView: true, + mapping: { + hostname: { + value: (record) => { return record.hostname } + }, + port: { + value: (record) => { return record.port } + }, + domainid: { + value: (record) => { return record.domainid } + } + } + }, { api: 'deleteLdapConfiguration', icon: 'delete-outlined', diff --git a/ui/src/core/lazy_lib/icons_use.js b/ui/src/core/lazy_lib/icons_use.js index ed7b883ddbb7..f7ea2fdfcdda 100644 --- a/ui/src/core/lazy_lib/icons_use.js +++ b/ui/src/core/lazy_lib/icons_use.js @@ -78,6 +78,7 @@ import { EnvironmentOutlined, ExceptionOutlined, ExclamationCircleOutlined, + ExperimentOutlined, EyeInvisibleOutlined, EyeOutlined, FieldTimeOutlined, @@ -254,6 +255,7 @@ export default { app.component('EnvironmentOutlined', EnvironmentOutlined) app.component('ExceptionOutlined', ExceptionOutlined) app.component('ExclamationCircleOutlined', ExclamationCircleOutlined) + app.component('ExperimentOutlined', ExperimentOutlined) app.component('EyeInvisibleOutlined', EyeInvisibleOutlined) app.component('EyeOutlined', EyeOutlined) app.component('FieldTimeOutlined', FieldTimeOutlined)