From aabf0c3813d5f5962f47dd1382589787241ccdfe Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 7 Jan 2026 13:03:22 +0200 Subject: [PATCH 01/54] Improve validation for user credentials on login --- .../security/system/DefaultSystemSecurityService.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java index 69536f0953..a977def292 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java @@ -80,11 +80,15 @@ public class DefaultSystemSecurityService implements SystemSecurityService { @Override public void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String username, String password) throws AuthenticationException { + if (!userCredentials.isEnabled()) { + throw new DisabledException("User is not active"); + } + if (!encoder.matches(password, userCredentials.getPassword())) { int failedLoginAttempts = userService.increaseFailedLoginAttempts(tenantId, userCredentials.getUserId()); SecuritySettings securitySettings = securitySettingsService.getSecuritySettings(); if (securitySettings.getMaxFailedLoginAttempts() != null && securitySettings.getMaxFailedLoginAttempts() > 0) { - if (failedLoginAttempts > securitySettings.getMaxFailedLoginAttempts() && userCredentials.isEnabled()) { + if (failedLoginAttempts > securitySettings.getMaxFailedLoginAttempts()) { lockAccount(userCredentials.getUserId(), username, securitySettings.getUserLockoutNotificationEmail(), securitySettings.getMaxFailedLoginAttempts()); throw new LockedException("Authentication Failed. Username was locked due to security policy."); } @@ -92,10 +96,6 @@ public class DefaultSystemSecurityService implements SystemSecurityService { throw new BadCredentialsException("Authentication Failed. Username or Password not valid."); } - if (!userCredentials.isEnabled()) { - throw new DisabledException("User is not active"); - } - userService.resetFailedLoginAttempts(tenantId, userCredentials.getUserId()); SecuritySettings securitySettings = securitySettingsService.getSecuritySettings(); From 4363e53ff4782b7e7343b05ca13c1a6c97bcc620 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 8 Jan 2026 12:35:27 +0200 Subject: [PATCH 02/54] Add DefaultSystemSecurityServiceTest --- .../DefaultSystemSecurityServiceTest.java | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 application/src/test/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityServiceTest.java diff --git a/application/src/test/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityServiceTest.java new file mode 100644 index 0000000000..05e51177af --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityServiceTest.java @@ -0,0 +1,204 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * 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.thingsboard.server.service.security.system; + +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.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.thingsboard.rule.engine.api.MailService; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.common.data.security.model.SecuritySettings; +import org.thingsboard.server.common.data.security.model.UserPasswordPolicy; +import org.thingsboard.server.dao.audit.AuditLogService; +import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.dao.settings.SecuritySettingsService; +import org.thingsboard.server.dao.user.UserService; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class DefaultSystemSecurityServiceTest { + + @Mock + private AdminSettingsService adminSettingsService; + @Mock + private BCryptPasswordEncoder encoder; + @Mock + private UserService userService; + @Mock + private MailService mailService; + @Mock + private AuditLogService auditLogService; + @Mock + private SecuritySettingsService securitySettingsService; + + private DefaultSystemSecurityService systemSecurityService; + + private TenantId tenantId; + private UserId userId; + private UserCredentials userCredentials; + private SecuritySettings securitySettings; + private String username; + private String password; + private String encodedPassword; + + @Before + public void setUp() { + systemSecurityService = new DefaultSystemSecurityService(adminSettingsService, encoder, userService, mailService, auditLogService, securitySettingsService); + + tenantId = TenantId.fromUUID(UUID.randomUUID()); + userId = new UserId(UUID.randomUUID()); + username = "tenant@example.com"; + password = "correctPassword"; + encodedPassword = "$2a$10$encodedPasswordHash"; + + userCredentials = new UserCredentials(); + userCredentials.setUserId(userId); + userCredentials.setEnabled(true); + userCredentials.setPassword(encodedPassword); + userCredentials.setCreatedTime(System.currentTimeMillis()); + + securitySettings = new SecuritySettings(); + securitySettings.setMaxFailedLoginAttempts(5); + securitySettings.setPasswordPolicy(new UserPasswordPolicy()); + } + + @Test + public void testValidateUserCredentials_successfulLogin() { + when(encoder.matches(password, encodedPassword)).thenReturn(true); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password); + + verify(encoder).matches(password, encodedPassword); + verify(userService).resetFailedLoginAttempts(tenantId, userId); + verify(userService, never()).increaseFailedLoginAttempts(any(), any()); + } + + @Test + public void testValidateUserCredentials_wrongPassword_incrementsFailedAttempts() { + when(encoder.matches(password, encodedPassword)).thenReturn(false); + when(userService.increaseFailedLoginAttempts(tenantId, userId)).thenReturn(3); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(BadCredentialsException.class) + .hasMessageContaining("Authentication Failed"); + + verify(userService).increaseFailedLoginAttempts(tenantId, userId); + verify(userService, never()).setUserCredentialsEnabled(any(), any(), eq(false)); + } + + @Test + public void testValidateUserCredentials_wrongPassword_accountLocked() { + when(encoder.matches(password, encodedPassword)).thenReturn(false); + when(userService.increaseFailedLoginAttempts(tenantId, userId)).thenReturn(6); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(LockedException.class) + .hasMessageContaining("locked due to security policy"); + + verify(userService).increaseFailedLoginAttempts(tenantId, userId); + verify(userService).setUserCredentialsEnabled(TenantId.SYS_TENANT_ID, userId, false); + } + + @Test + public void testValidateUserCredentials_wrongPassword_exactlyAtThreshold_noLock() { + when(encoder.matches(password, encodedPassword)).thenReturn(false); + when(userService.increaseFailedLoginAttempts(tenantId, userId)).thenReturn(5); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(BadCredentialsException.class) + .hasMessageContaining("Authentication Failed"); + + verify(userService).increaseFailedLoginAttempts(tenantId, userId); + verify(userService, never()).setUserCredentialsEnabled(any(), any(), eq(false)); + } + + @Test + public void testValidateUserCredentials_correctPassword_disabledUser_throwsDisabledException() { + userCredentials.setEnabled(false); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(DisabledException.class) + .hasMessage("User is not active"); + + verify(encoder, never()).matches(any(), any()); + verify(userService, never()).increaseFailedLoginAttempts(any(), any()); + } + + @Test + public void testValidateUserCredentials_wrongPassword_maxAttemptsDisabled() { + securitySettings.setMaxFailedLoginAttempts(null); + when(encoder.matches(password, encodedPassword)).thenReturn(false); + when(userService.increaseFailedLoginAttempts(tenantId, userId)).thenReturn(100); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(BadCredentialsException.class); + + verify(userService).increaseFailedLoginAttempts(tenantId, userId); + verify(userService, never()).setUserCredentialsEnabled(any(), any(), eq(false)); + } + + @Test + public void testValidateUserCredentials_wrongPassword_maxAttemptsSetToZero() { + securitySettings.setMaxFailedLoginAttempts(0); + when(encoder.matches(password, encodedPassword)).thenReturn(false); + when(userService.increaseFailedLoginAttempts(tenantId, userId)).thenReturn(100); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(BadCredentialsException.class); + + verify(userService).increaseFailedLoginAttempts(tenantId, userId); + verify(userService, never()).setUserCredentialsEnabled(any(), any(), eq(false)); + } + + @Test + public void testValidateUserCredentials_wrongPassword_withNotificationEmail() throws ThingsboardException { + String notificationEmail = "admin@example.com"; + securitySettings.setUserLockoutNotificationEmail(notificationEmail); + when(encoder.matches(password, encodedPassword)).thenReturn(false); + when(userService.increaseFailedLoginAttempts(tenantId, userId)).thenReturn(6); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(LockedException.class); + + verify(userService).setUserCredentialsEnabled(TenantId.SYS_TENANT_ID, userId, false); + verify(mailService).sendAccountLockoutEmail(eq(username), eq(notificationEmail), eq(5)); + } + +} From 9e748520e848daaf23254777df6e3ba70fcf418f Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Fri, 9 Jan 2026 14:52:31 +0200 Subject: [PATCH 03/54] Fix invalid notification targets search by type --- .../NotificationTargetApiTest.java | 28 +++++++++++++++++++ .../JpaNotificationTargetDao.java | 2 +- .../NotificationTargetRepository.java | 15 +++++++--- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationTargetApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationTargetApiTest.java index adfe8a2435..2d865542a1 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationTargetApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationTargetApiTest.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.notification; import com.fasterxml.jackson.core.type.TypeReference; +import org.apache.commons.lang3.RandomStringUtils; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -30,7 +31,12 @@ import org.thingsboard.server.common.data.notification.targets.NotificationTarge import org.thingsboard.server.common.data.notification.targets.platform.AllUsersFilter; import org.thingsboard.server.common.data.notification.targets.platform.CustomerUsersFilter; import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig; +import org.thingsboard.server.common.data.notification.targets.platform.SystemAdministratorsFilter; import org.thingsboard.server.common.data.notification.targets.platform.UserListFilter; +import org.thingsboard.server.common.data.notification.targets.platform.UsersFilterType; +import org.thingsboard.server.common.data.notification.targets.slack.SlackConversation; +import org.thingsboard.server.common.data.notification.targets.slack.SlackConversationType; +import org.thingsboard.server.common.data.notification.targets.slack.SlackNotificationTargetConfig; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.notification.NotificationTargetDao; @@ -175,6 +181,28 @@ public class NotificationTargetApiTest extends AbstractNotificationApiTest { assertThat(error).containsIgnoringCase("referenced by scheduled notification request"); } + @Test + public void findByTenantIdAndUsersFilterType() throws Exception { + loginSysAdmin(); + NotificationTarget sysAdmins = createNotificationTarget(new SystemAdministratorsFilter()); + + NotificationTarget slack = new NotificationTarget(); + slack.setName(RandomStringUtils.randomNumeric(5)); + SlackNotificationTargetConfig slackConfig = new SlackNotificationTargetConfig(); + SlackConversation slackConversation = new SlackConversation(); + slackConversation.setType(SlackConversationType.DIRECT); + slackConversation.setId("U12345678"); + slackConversation.setName("test"); + slackConfig.setConversation(slackConversation); + slack.setConfiguration(slackConfig); + slack = saveNotificationTarget(slack); + + List sysAdminTargets = notificationTargetDao.findByTenantIdAndUsersFilterType( + TenantId.SYS_TENANT_ID, UsersFilterType.SYSTEM_ADMINISTRATORS + ); + assertThat(sysAdminTargets).containsOnly(sysAdmins); + } + private String saveAndGetError(NotificationTarget notificationTarget, ResultMatcher statusMatcher) throws Exception { return getErrorMessage(save(notificationTarget, statusMatcher)); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationTargetDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationTargetDao.java index d05172e255..00dddd0114 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationTargetDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationTargetDao.java @@ -67,7 +67,7 @@ public class JpaNotificationTargetDao extends JpaAbstractDao findByTenantIdAndUsersFilterType(TenantId tenantId, UsersFilterType filterType) { - return DaoUtil.convertDataList(notificationTargetRepository.findByTenantIdAndSearchTextAndUsersFilterTypeIfPresent(tenantId.getId(), null, + return DaoUtil.convertDataList(notificationTargetRepository.findByTenantIdAndUsersFilterType(tenantId.getId(), List.of(filterType.name()), DaoUtil.toPageable(new PageLink(Integer.MAX_VALUE))).getContent()); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/NotificationTargetRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/NotificationTargetRepository.java index 961e734c9e..e807a6bffb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/NotificationTargetRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/NotificationTargetRepository.java @@ -33,20 +33,27 @@ import java.util.UUID; public interface NotificationTargetRepository extends JpaRepository, ExportableEntityRepository { @Query("SELECT t FROM NotificationTargetEntity t WHERE t.tenantId = :tenantId " + - "AND (:searchText is NULL OR ilike(t.name, concat('%', :searchText, '%')) = true)") + "AND (:searchText is NULL OR ilike(t.name, concat('%', :searchText, '%')) = true)") Page findByTenantIdAndSearchText(@Param("tenantId") UUID tenantId, @Param("searchText") String searchText, Pageable pageable); @Query(value = "SELECT * FROM notification_target t WHERE t.tenant_id = :tenantId " + - "AND (:searchText IS NULL OR t.name ILIKE concat('%', :searchText, '%')) " + - "AND (cast(t.configuration as json) ->> 'type' <> 'PLATFORM_USERS' OR " + - "cast(t.configuration as json) -> 'usersFilter' ->> 'type' IN :usersFilterTypes)", nativeQuery = true) + "AND (:searchText IS NULL OR t.name ILIKE concat('%', :searchText, '%')) " + + "AND (cast(t.configuration as json) ->> 'type' <> 'PLATFORM_USERS' OR " + + "cast(t.configuration as json) -> 'usersFilter' ->> 'type' IN :usersFilterTypes)", nativeQuery = true) Page findByTenantIdAndSearchTextAndUsersFilterTypeIfPresent(@Param("tenantId") UUID tenantId, @Param("searchText") String searchText, @Param("usersFilterTypes") List usersFilterTypes, Pageable pageable); + @Query(value = "SELECT * FROM notification_target t WHERE t.tenant_id = :tenantId " + + "AND (cast(t.configuration as json) ->> 'type' = 'PLATFORM_USERS' AND " + + "cast(t.configuration as json) -> 'usersFilter' ->> 'type' IN :usersFilterTypes)", nativeQuery = true) + Page findByTenantIdAndUsersFilterType(@Param("tenantId") UUID tenantId, + @Param("usersFilterTypes") List usersFilterTypes, + Pageable pageable); + List findByTenantIdAndIdIn(UUID tenantId, List ids); @Transactional From 38d4f23c5eb657483c0b21b927e15233941923ff Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 13 Jan 2026 17:12:41 +0200 Subject: [PATCH 04/54] Fix CONF_FOLDER for start-tb-edqs.sh in k8s --- msa/edqs/docker/start-tb-edqs.sh | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/msa/edqs/docker/start-tb-edqs.sh b/msa/edqs/docker/start-tb-edqs.sh index 40970ead21..4968f5b866 100755 --- a/msa/edqs/docker/start-tb-edqs.sh +++ b/msa/edqs/docker/start-tb-edqs.sh @@ -15,11 +15,19 @@ # limitations under the License. # -CONF_FOLDER=${pkg.installFolder}/conf jarfile=${pkg.installFolder}/bin/${pkg.name}.jar configfile=${pkg.name}.conf -source "${CONF_FOLDER}/${configfile}" +CONF_FOLDER="/config" +if [ -d "${CONF_FOLDER}" ]; then + LOGGING_CONFIG="${CONF_FOLDER}/logback.xml" + source "${CONF_FOLDER}/${configfile}" + export LOADER_PATH=${CONF_FOLDER},${LOADER_PATH} +else + CONF_FOLDER="/usr/share/${pkg.name}/conf" + LOGGING_CONFIG="/usr/share/${pkg.name}/conf/logback.xml" + source "${CONF_FOLDER}/${configfile}" +fi echo "Starting '${project.name}' ..." @@ -27,5 +35,5 @@ cd ${pkg.installFolder}/bin exec java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.edqs.ThingsboardEdqsApplication \ -Dspring.jpa.hibernate.ddl-auto=none \ - -Dlogging.config=$CONF_FOLDER/logback.xml \ + -Dlogging.config=${LOGGING_CONFIG} \ org.springframework.boot.loader.launch.PropertiesLauncher From 32f7c2abf744caeddd45e3eed843f145176ead45 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 13 Jan 2026 17:44:31 +0200 Subject: [PATCH 05/54] fixed smsEnabled property handling for tenant profile configuration --- .../DefaultTbApiUsageStateServiceTest.java | 84 +++++++++++++++++++ .../usagerecord/ApiUsageStateServiceImpl.java | 28 +++++-- 2 files changed, 103 insertions(+), 9 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java b/application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java index 7530c646f4..a358c0f12c 100644 --- a/application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.id.ApiUsageStateId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.common.msg.queue.TbCallback; @@ -40,6 +41,7 @@ import java.lang.reflect.Field; import java.time.LocalDate; import java.util.HashMap; import java.util.Map; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -47,7 +49,10 @@ import static java.time.ZoneOffset.UTC; import static java.time.temporal.ChronoField.DAY_OF_MONTH; import static java.time.temporal.ChronoUnit.MONTHS; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; import static org.junit.Assert.assertEquals; +import static org.thingsboard.server.common.data.ApiFeature.SMS; +import static org.thingsboard.server.common.data.ApiUsageRecordKey.SMS_EXEC_COUNT; @DaoSqlTest public class DefaultTbApiUsageStateServiceTest extends AbstractControllerTest { @@ -157,6 +162,85 @@ public class DefaultTbApiUsageStateServiceTest extends AbstractControllerTest { assertThat(tenantApiUsageState.getNextCycleTs()).isEqualTo(firstOfNextMonth); } + @Test + public void checkTenantCreatedWithSmsDisabledApiUsage() throws Exception { + TenantProfile tenantProfile = new TenantProfile(); + tenantProfile.setName("Test profile"); + TenantProfileData tenantProfileData = new TenantProfileData(); + DefaultTenantProfileConfiguration config = DefaultTenantProfileConfiguration.builder() + .smsEnabled(false) + .build(); + + tenantProfileData.setConfiguration(config); + tenantProfile.setProfileData(tenantProfileData); + tenantProfile = doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); + + Tenant tenant = new Tenant(); + tenant.setTitle("My test tenant"); + tenant.setTenantProfileId(tenantProfile.getId()); + tenant = saveTenant(tenant); + + ApiUsageState apiUsageState = apiUsageStateService.findApiUsageStateByEntityId(tenant.getId()); + assertThat(apiUsageState.getSmsExecState()).isEqualTo(ApiUsageStateValue.DISABLED); + + TenantId finalTenantId = tenant.getId(); + ApiUsageStateId finalApiUsageStateId = apiUsageState.getId(); + + + await().atMost(5, TimeUnit.SECONDS).until(() -> { + Optional smsApiState = tsService.findLatest(finalTenantId, finalApiUsageStateId, SMS.getApiStateKey()).get(); + return smsApiState.isPresent() && smsApiState.get().getValueAsString().equals(ApiUsageStateValue.DISABLED.name()); + }); + + await().atMost(5, TimeUnit.SECONDS).until(() -> { + Optional smsApiState = tsService.findLatest(finalTenantId, finalApiUsageStateId, SMS_EXEC_COUNT.getApiLimitKey()).get(); + return smsApiState.isPresent() && smsApiState.get().getLongValue().get().equals(0L); + }); + + // enable SMS and check that the ApiUsageState is updated accordingly + config = DefaultTenantProfileConfiguration.builder() + .smsEnabled(true) + .maxSms(10) + .build(); + tenantProfileData.setConfiguration(config); + tenantProfile.setProfileData(tenantProfileData); + doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); + + await().atMost(5, TimeUnit.SECONDS).until(() -> + apiUsageStateService.findApiUsageStateByEntityId(finalTenantId).getSmsExecState() == ApiUsageStateValue.ENABLED); + + await().atMost(5, TimeUnit.SECONDS).until(() -> { + Optional smsApiState = tsService.findLatest(finalTenantId, finalApiUsageStateId, SMS.getApiStateKey()).get(); + return smsApiState.isPresent() && smsApiState.get().getValueAsString().equals(ApiUsageStateValue.ENABLED.name()); + }); + + await().atMost(5, TimeUnit.SECONDS).until(() -> { + Optional smsApiState = tsService.findLatest(finalTenantId, finalApiUsageStateId, SMS_EXEC_COUNT.getApiLimitKey()).get(); + return smsApiState.isPresent() && smsApiState.get().getLongValue().get().equals(10L); + }); + + //disable SMS and check that the ApiUsageState is updated accordingly + config = DefaultTenantProfileConfiguration.builder() + .smsEnabled(false) + .build(); + tenantProfileData.setConfiguration(config); + tenantProfile.setProfileData(tenantProfileData); + doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); + + await().atMost(5, TimeUnit.SECONDS).until(() -> + apiUsageStateService.findApiUsageStateByEntityId(finalTenantId).getSmsExecState() == ApiUsageStateValue.DISABLED); + + await().atMost(5, TimeUnit.SECONDS).until(() -> { + Optional smsApiState = tsService.findLatest(finalTenantId, finalApiUsageStateId, SMS.getApiStateKey()).get(); + return smsApiState.isPresent() && smsApiState.get().getValueAsString().equals(ApiUsageStateValue.DISABLED.name()); + }); + + await().atMost(5, TimeUnit.SECONDS).until(() -> { + Optional smsApiState = tsService.findLatest(finalTenantId, finalApiUsageStateId, SMS_EXEC_COUNT.getApiLimitKey()).get(); + return smsApiState.isPresent() && smsApiState.get().getLongValue().get().equals(0L); + }); + } + private TenantProfile createTenantProfile() { TenantProfile tenantProfile = new TenantProfile(); tenantProfile.setName("Tenant Profile"); diff --git a/dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateServiceImpl.java index b97c81c612..7f0e507f5f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateServiceImpl.java @@ -35,7 +35,7 @@ import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; -import org.thingsboard.server.common.data.tenant.profile.TenantProfileConfiguration; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.dao.entity.AbstractEntityService; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; @@ -101,6 +101,21 @@ public class ApiUsageStateServiceImpl extends AbstractEntityService implements A entityId = Objects.requireNonNullElse(entityId, tenantId); log.trace("Executing createDefaultUsageRecord [{}]", entityId); validateId(tenantId, id -> INCORRECT_TENANT_ID + id); + + ApiUsageStateValue smsApiUsageState = ApiUsageStateValue.ENABLED; + + DefaultTenantProfileConfiguration configuration = null; + if (entityId.getEntityType() == EntityType.TENANT && !entityId.equals(TenantId.SYS_TENANT_ID)) { + tenantId = (TenantId) entityId; + Tenant tenant = tenantService.findTenantById(tenantId); + TenantProfile tenantProfile = tenantProfileDao.findById(tenantId, tenant.getTenantProfileId().getId()); + configuration = (DefaultTenantProfileConfiguration) tenantProfile.getProfileData().getConfiguration(); + + if (configuration.getSmsEnabled() != null && !configuration.getSmsEnabled()) { + smsApiUsageState = ApiUsageStateValue.DISABLED; + } + } + ApiUsageState apiUsageState = new ApiUsageState(); apiUsageState.setTenantId(tenantId); apiUsageState.setEntityId(entityId); @@ -109,7 +124,7 @@ public class ApiUsageStateServiceImpl extends AbstractEntityService implements A apiUsageState.setJsExecState(ApiUsageStateValue.ENABLED); apiUsageState.setTbelExecState(ApiUsageStateValue.ENABLED); apiUsageState.setDbStorageState(ApiUsageStateValue.ENABLED); - apiUsageState.setSmsExecState(ApiUsageStateValue.ENABLED); + apiUsageState.setSmsExecState(smsApiUsageState); apiUsageState.setEmailExecState(ApiUsageStateValue.ENABLED); apiUsageState.setAlarmExecState(ApiUsageStateValue.ENABLED); apiUsageStateValidator.validate(apiUsageState, ApiUsageState::getTenantId); @@ -138,17 +153,12 @@ public class ApiUsageStateServiceImpl extends AbstractEntityService implements A apiUsageStates.add(new BasicTsKvEntry(saved.getCreatedTime(), new StringDataEntry(ApiFeature.EMAIL.getApiStateKey(), ApiUsageStateValue.ENABLED.name()))); apiUsageStates.add(new BasicTsKvEntry(saved.getCreatedTime(), - new StringDataEntry(ApiFeature.SMS.getApiStateKey(), ApiUsageStateValue.ENABLED.name()))); + new StringDataEntry(ApiFeature.SMS.getApiStateKey(), smsApiUsageState.name()))); apiUsageStates.add(new BasicTsKvEntry(saved.getCreatedTime(), new StringDataEntry(ApiFeature.ALARM.getApiStateKey(), ApiUsageStateValue.ENABLED.name()))); tsService.save(tenantId, saved.getId(), apiUsageStates, 0L); - if (entityId.getEntityType() == EntityType.TENANT && !entityId.equals(TenantId.SYS_TENANT_ID)) { - tenantId = (TenantId) entityId; - Tenant tenant = tenantService.findTenantById(tenantId); - TenantProfile tenantProfile = tenantProfileDao.findById(tenantId, tenant.getTenantProfileId().getId()); - TenantProfileConfiguration configuration = tenantProfile.getProfileData().getConfiguration(); - + if (configuration != null) { List profileThresholds = new ArrayList<>(); for (ApiUsageRecordKey key : ApiUsageRecordKey.values()) { if (key.getApiLimitKey() == null) continue; From ec128f3e855e39259f05329471c626c0f3818a7b Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 13 Jan 2026 18:28:26 +0200 Subject: [PATCH 06/54] null value for smsEnabled is interpreted as enabled on backend --- .../tenant/default-tenant-profile-configuration.component.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts index fe8c4af871..4f1efa32d2 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts @@ -20,7 +20,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@app/core/core.state'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { DefaultTenantProfileConfiguration, TenantProfileConfiguration } from '@shared/models/tenant.model'; -import { isDefinedAndNotNull } from '@core/utils'; +import { isDefinedAndNotNull, isUndefinedOrNull} from '@core/utils'; import { RateLimitsType } from './rate-limits/rate-limits.models'; import { takeUntil } from 'rxjs/operators'; import { Subject } from 'rxjs'; @@ -179,6 +179,9 @@ export class DefaultTenantProfileConfigurationComponent implements ControlValueA writeValue(value: DefaultTenantProfileConfiguration | null): void { if (isDefinedAndNotNull(value)) { + if (isUndefinedOrNull(value.smsEnabled)) { + value.smsEnabled = true; + } this.maxSmsValidation(value.smsEnabled); this.defaultTenantProfileConfigurationFormGroup.patchValue(value, {emitEvent: false}); } From 48c31e47b9743baedc64424d69130430fd41e1e7 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Wed, 14 Jan 2026 12:59:23 +0200 Subject: [PATCH 07/54] UI: Fixed autofill country autocomplete and improved validation --- ui-ngx/src/app/core/utils.ts | 14 ++++++- .../country-autocomplete.component.html | 13 ++++++- .../country-autocomplete.component.scss | 27 +++++++++++++ .../country-autocomplete.component.ts | 39 ++++++++++++++++--- .../assets/locale/locale.constant-en_US.json | 1 + 5 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 ui-ngx/src/app/shared/components/country-autocomplete.component.scss diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index f1a0671a52..e0665d4eb1 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -27,13 +27,14 @@ import { serverErrorCodesTranslations } from '@shared/models/constants'; import { SubscriptionEntityInfo } from '@core/api/widget-api.models'; import { CompiledTbFunction, - compileTbFunction, GenericFunction, + compileTbFunction, + GenericFunction, isNotEmptyTbFunction, TbFunction } from '@shared/models/js-function.models'; import { DomSanitizer } from '@angular/platform-browser'; import { SecurityContext } from '@angular/core'; -import { AbstractControl, ValidationErrors, Validators } from '@angular/forms'; +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; const varsRegex = /\${([^}]*)}/g; const emailRegex = /^[A-Z0-9_!#$%&'*+/=?`{|}~^.-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i; @@ -998,3 +999,12 @@ export const validateEmail = (control: AbstractControl): ValidationErrors | null return emailRegex.test(control.value) ? null : {email: true}; }; +export const requireMatch = (): ValidatorFn => { + return (control: AbstractControl): ValidationErrors | null => { + const value = control.value; + if (value && typeof value === 'string') { + return { requireMatch: { value: value } }; + } + return null; + }; +} diff --git a/ui-ngx/src/app/shared/components/country-autocomplete.component.html b/ui-ngx/src/app/shared/components/country-autocomplete.component.html index 9b3857cafb..61a874431a 100644 --- a/ui-ngx/src/app/shared/components/country-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/country-autocomplete.component.html @@ -15,13 +15,19 @@ limitations under the License. --> - + {{ labelText }} + @if (countryFormGroup.get('country').value?.flag) { + + {{ countryFormGroup.get('country').value.flag }} + + }