diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java index 23e6dc065f..ad2ecebd2c 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java @@ -56,12 +56,31 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager { @Override public Optional getAccountTwoFaSettings(TenantId tenantId, UserId userId) { + PlatformTwoFaSettings platformTwoFaSettings = getPlatformTwoFaSettings(tenantId, true).orElse(null); return Optional.ofNullable(userAuthSettingsDao.findByUserId(userId)) - .flatMap(userAuthSettings -> Optional.ofNullable(userAuthSettings.getTwoFaSettings())) - .map(twoFaSettings -> { - twoFaSettings.getConfigs().keySet().removeIf(providerType -> { - return getTwoFaProviderConfig(tenantId, providerType).isEmpty(); + .map(userAuthSettings -> { + AccountTwoFaSettings twoFaSettings = userAuthSettings.getTwoFaSettings(); + if (twoFaSettings == null) return null; + boolean updateNeeded; + + Map configs = twoFaSettings.getConfigs(); + updateNeeded = configs.keySet().removeIf(providerType -> { + return platformTwoFaSettings == null || platformTwoFaSettings.getProviderConfig(providerType).isEmpty(); }); + if (configs.size() == 1 && configs.containsKey(TwoFaProviderType.BACKUP_CODE)) { + configs.remove(TwoFaProviderType.BACKUP_CODE); + updateNeeded = true; + } + if (!configs.isEmpty() && configs.values().stream().noneMatch(TwoFaAccountConfig::isUseByDefault)) { + configs.values().stream() + .filter(config -> config.getProviderType() != TwoFaProviderType.BACKUP_CODE) + .findFirst().ifPresent(config -> config.setUseByDefault(true)); + updateNeeded = true; + } + + if (updateNeeded) { + twoFaSettings = saveAccountTwoFaSettings(tenantId, userId, twoFaSettings); + } return twoFaSettings; }); } diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java index ee93cadaa4..db15e6fd82 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java @@ -96,6 +96,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); twoFaSettings.setProviders(List.of(totpTwoFaProviderConfig, smsTwoFaProviderConfig)); + twoFaSettings.setMinVerificationCodeSendPeriod(5); twoFaSettings.setVerificationCodeCheckRateLimit("3:900"); twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10); twoFaSettings.setTotalAllowedTimeForVerification(3600); @@ -117,6 +118,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { twoFaSettings.setVerificationCodeCheckRateLimit("0:12"); twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(-1); twoFaSettings.setTotalAllowedTimeForVerification(0); + twoFaSettings.setMinVerificationCodeSendPeriod(5); String errorMessage = getErrorMessage(doPost("/api/2fa/settings", twoFaSettings) .andExpect(status().isBadRequest())); @@ -156,6 +158,8 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { private String savePlatformTwoFaSettingsAndGetError(TwoFaProviderConfig invalidTwoFaProviderConfig) throws Exception { PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); twoFaSettings.setProviders(Collections.singletonList(invalidTwoFaProviderConfig)); + twoFaSettings.setMinVerificationCodeSendPeriod(5); + twoFaSettings.setTotalAllowedTimeForVerification(100); return getErrorMessage(doPost("/api/2fa/settings", twoFaSettings) .andExpect(status().isBadRequest())); @@ -432,8 +436,9 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { private void saveProvidersConfigs(TwoFaProviderConfig... providerConfigs) throws Exception { PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); - twoFaSettings.setProviders(Arrays.stream(providerConfigs).collect(Collectors.toList())); + twoFaSettings.setMinVerificationCodeSendPeriod(5); + twoFaSettings.setTotalAllowedTimeForVerification(100); doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk()); } diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java index b825571694..9c30ebbbd0 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java @@ -355,6 +355,8 @@ public abstract class TwoFactorAuthTest extends AbstractControllerTest { emailTwoFaProviderConfig.setVerificationCodeLifetime(60); platformTwoFaSettings.setProviders(List.of(totpTwoFaProviderConfig, smsTwoFaProviderConfig, emailTwoFaProviderConfig)); + platformTwoFaSettings.setMinVerificationCodeSendPeriod(5); + platformTwoFaSettings.setTotalAllowedTimeForVerification(100); twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, platformTwoFaSettings); User twoFaUser = new User(); @@ -409,6 +411,8 @@ public abstract class TwoFactorAuthTest extends AbstractControllerTest { PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); twoFaSettings.setProviders(Arrays.stream(new TwoFaProviderConfig[]{totpTwoFaProviderConfig}).collect(Collectors.toList())); + twoFaSettings.setMinVerificationCodeSendPeriod(5); + twoFaSettings.setTotalAllowedTimeForVerification(100); Arrays.stream(customizer).forEach(c -> c.accept(twoFaSettings)); twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); @@ -425,6 +429,8 @@ public abstract class TwoFactorAuthTest extends AbstractControllerTest { PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); twoFaSettings.setProviders(Arrays.stream(new TwoFaProviderConfig[]{smsTwoFaProviderConfig}).collect(Collectors.toList())); + twoFaSettings.setMinVerificationCodeSendPeriod(5); + twoFaSettings.setTotalAllowedTimeForVerification(100); twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmSchedule.java index 0c9ebc0d2a..f052ba6a72 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmSchedule.java @@ -18,6 +18,7 @@ package org.thingsboard.server.common.data.device.profile; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.thingsboard.server.common.data.query.DynamicValue; import java.io.Serializable; @@ -34,4 +35,6 @@ public interface AlarmSchedule extends Serializable { AlarmScheduleType getType(); + DynamicValue getDynamicValue(); + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AnyTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AnyTimeSchedule.java index 3db436eeb8..c425715aee 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AnyTimeSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AnyTimeSchedule.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.device.profile; +import org.thingsboard.server.common.data.query.DynamicValue; + public class AnyTimeSchedule implements AlarmSchedule { @Override @@ -22,4 +24,9 @@ public class AnyTimeSchedule implements AlarmSchedule { return AlarmScheduleType.ANY_TIME; } + @Override + public DynamicValue getDynamicValue() { + return null; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeSchedule.java index 71c111cd08..c48326c507 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeSchedule.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.device.profile; import lombok.Data; +import org.thingsboard.server.common.data.query.DynamicValue; import java.util.List; @@ -25,6 +26,8 @@ public class CustomTimeSchedule implements AlarmSchedule { private String timezone; private List items; + private DynamicValue dynamicValue; + @Override public AlarmScheduleType getType() { return AlarmScheduleType.CUSTOM; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SpecificTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SpecificTimeSchedule.java index 55f0e7c4ca..1c75635deb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SpecificTimeSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SpecificTimeSchedule.java @@ -16,8 +16,8 @@ package org.thingsboard.server.common.data.device.profile; import lombok.Data; +import org.thingsboard.server.common.data.query.DynamicValue; -import java.util.List; import java.util.Set; @Data @@ -28,6 +28,8 @@ public class SpecificTimeSchedule implements AlarmSchedule { private long startsOn; private long endsOn; + private DynamicValue dynamicValue; + @Override public AlarmScheduleType getType() { return AlarmScheduleType.SPECIFIC_TIME; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java index fd72e7a027..ec2acf8733 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java @@ -35,12 +35,14 @@ public class PlatformTwoFaSettings { @NotNull private List providers; + @NotNull @Min(value = 5, message = "minimum verification code sent period must be greater than or equal 5") private Integer minVerificationCodeSendPeriod; @Pattern(regexp = "[1-9]\\d*:[1-9]\\d*", message = "verification code check rate limit configuration is invalid") private String verificationCodeCheckRateLimit; @Min(value = 0, message = "maximum number of verification failure before user lockout must be positive") private Integer maxVerificationFailuresBeforeUserLockout; + @NotNull @Min(value = 60, message = "total amount of time allotted for verification must be greater than or equal 60") private Integer totalAllowedTimeForVerification; diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java index d354821ce0..636b9554a5 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java @@ -575,6 +575,10 @@ public class JsonConverter { return JSON_PARSER.parse(json); } + public static T parse(String json, Class clazz) { + return fromJson(parse(json), clazz); + } + public static String toJson(JsonElement element) { return GSON.toJson(element); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java index bee996e0fc..34dfcab964 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java @@ -371,7 +371,7 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { try { return jdbcTemplate.queryForObject(countQuery, ctx, Long.class); } finally { - queryLog.logQuery(ctx, ctx.getQuery(), System.currentTimeMillis() - startTs); + queryLog.logQuery(ctx, countQuery, System.currentTimeMillis() - startTs); } }); } @@ -483,7 +483,7 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { try { rows = jdbcTemplate.queryForList(dataQuery, ctx); } finally { - queryLog.logQuery(ctx, countQuery, System.currentTimeMillis() - startTs); + queryLog.logQuery(ctx, dataQuery, System.currentTimeMillis() - startTs); } return EntityDataAdapter.createEntityData(pageLink, selectionMapping, rows, totalElements); }); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmRuleState.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmRuleState.java index 62508e5503..e8780a7fe6 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmRuleState.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmRuleState.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.device.profile.AlarmConditionSpec; import org.thingsboard.server.common.data.device.profile.AlarmConditionSpecType; import org.thingsboard.server.common.data.device.profile.AlarmRule; import org.thingsboard.server.common.data.device.profile.CustomTimeSchedule; +import org.thingsboard.server.common.data.device.profile.AlarmSchedule; import org.thingsboard.server.common.data.device.profile.CustomTimeScheduleItem; import org.thingsboard.server.common.data.device.profile.DurationAlarmConditionSpec; import org.thingsboard.server.common.data.device.profile.RepeatingAlarmConditionSpec; @@ -40,6 +41,7 @@ import org.thingsboard.server.common.data.query.KeyFilterPredicate; import org.thingsboard.server.common.data.query.NumericFilterPredicate; import org.thingsboard.server.common.data.query.StringFilterPredicate; import org.thingsboard.server.common.msg.tools.SchedulerUtils; +import org.thingsboard.server.common.transport.adaptor.JsonConverter; import java.time.Instant; import java.time.ZoneId; @@ -115,7 +117,7 @@ class AlarmRuleState { } public AlarmEvalResult eval(DataSnapshot data) { - boolean active = isActive(data.getTs()); + boolean active = isActive(data, data.getTs()); switch (spec.getType()) { case SIMPLE: return (active && eval(alarmRule.getCondition(), data)) ? AlarmEvalResult.TRUE : AlarmEvalResult.FALSE; @@ -128,7 +130,7 @@ class AlarmRuleState { } } - private boolean isActive(long eventTs) { + private boolean isActive(DataSnapshot data, long eventTs) { if (eventTs == 0L) { eventTs = System.currentTimeMillis(); } @@ -139,14 +141,28 @@ class AlarmRuleState { case ANY_TIME: return true; case SPECIFIC_TIME: - return isActiveSpecific((SpecificTimeSchedule) alarmRule.getSchedule(), eventTs); + return isActiveSpecific((SpecificTimeSchedule) getSchedule(data, alarmRule), eventTs); case CUSTOM: - return isActiveCustom((CustomTimeSchedule) alarmRule.getSchedule(), eventTs); + return isActiveCustom((CustomTimeSchedule) getSchedule(data, alarmRule), eventTs); default: throw new RuntimeException("Unsupported schedule type: " + alarmRule.getSchedule().getType()); } } + private AlarmSchedule getSchedule(DataSnapshot data, AlarmRule alarmRule) { + AlarmSchedule schedule = alarmRule.getSchedule(); + EntityKeyValue dynamicValue = getDynamicPredicateValue(data, schedule.getDynamicValue()); + + if (dynamicValue != null) { + try { + return JsonConverter.parse(dynamicValue.getJsonValue(), alarmRule.getSchedule().getClass()); + } catch (Exception e) { + log.trace("Failed to parse AlarmSchedule from dynamicValue: {}", dynamicValue.getJsonValue(), e); + } + } + return schedule; + } + private boolean isActiveSpecific(SpecificTimeSchedule schedule, long eventTs) { ZoneId zoneId = SchedulerUtils.getZoneId(schedule.getTimezone()); ZonedDateTime zdt = ZonedDateTime.ofInstant(Instant.ofEpochMilli(eventTs), zoneId); @@ -156,7 +172,13 @@ class AlarmRuleState { return false; } } - return isActive(eventTs, zoneId, zdt, schedule.getStartsOn(), schedule.getEndsOn()); + long endsOn = schedule.getEndsOn(); + if (endsOn == 0) { + // 24 hours in milliseconds + endsOn = 86400000; + } + + return isActive(eventTs, zoneId, zdt, schedule.getStartsOn(), endsOn); } private boolean isActiveCustom(CustomTimeSchedule schedule, long eventTs) { @@ -166,7 +188,12 @@ class AlarmRuleState { for (CustomTimeScheduleItem item : schedule.getItems()) { if (item.getDayOfWeek() == dayOfWeek) { if (item.isEnabled()) { - return isActive(eventTs, zoneId, zdt, item.getStartsOn(), item.getEndsOn()); + long endsOn = item.getEndsOn(); + if (endsOn == 0) { + // 24 hours in milliseconds + endsOn = 86400000; + } + return isActive(eventTs, zoneId, zdt, item.getStartsOn(), endsOn); } else { return false; } @@ -279,7 +306,7 @@ class AlarmRuleState { long requiredDurationInMs = resolveRequiredDurationInMs(dataSnapshot); if (requiredDurationInMs > 0 && state.getLastEventTs() > 0 && ts > state.getLastEventTs()) { long duration = state.getDuration() + (ts - state.getLastEventTs()); - if (isActive(ts)) { + if (isActive(dataSnapshot, ts)) { return duration > requiredDurationInMs ? AlarmEvalResult.TRUE : AlarmEvalResult.NOT_YET_TRUE; } else { return AlarmEvalResult.FALSE; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/ProfileState.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/ProfileState.java index 4cc3c9f6a6..c0f6ea29e0 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/ProfileState.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/ProfileState.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.device.profile.AlarmRule; import org.thingsboard.server.common.data.device.profile.DeviceProfileAlarm; import org.thingsboard.server.common.data.device.profile.DurationAlarmConditionSpec; import org.thingsboard.server.common.data.device.profile.RepeatingAlarmConditionSpec; +import org.thingsboard.server.common.data.device.profile.AlarmSchedule; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.query.ComplexFilterPredicate; import org.thingsboard.server.common.data.query.DynamicValue; @@ -77,6 +78,10 @@ class ProfileState { addDynamicValuesRecursively(keyFilter.getPredicate(), entityKeys, ruleKeys); } addEntityKeysFromAlarmConditionSpec(alarmRule); + AlarmSchedule schedule = alarmRule.getSchedule(); + if (schedule != null) { + addScheduleDynamicValues(schedule); + } })); if (alarm.getClearRule() != null) { var clearAlarmKeys = alarmClearKeys.computeIfAbsent(alarm.getId(), id -> new HashSet<>()); @@ -91,6 +96,16 @@ class ProfileState { } } + private void addScheduleDynamicValues(AlarmSchedule schedule) { + DynamicValue dynamicValue = schedule.getDynamicValue(); + if (dynamicValue != null) { + entityKeys.add( + new AlarmConditionFilterKey(AlarmConditionKeyType.ATTRIBUTE, + dynamicValue.getSourceAttribute()) + ); + } + } + private void addEntityKeysFromAlarmConditionSpec(AlarmRule alarmRule) { AlarmConditionSpec spec = alarmRule.getCondition().getSpec(); if (spec == null) { diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeTest.java index b00713cefa..a8c38ce33f 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeTest.java @@ -44,9 +44,12 @@ import org.thingsboard.server.common.data.device.profile.DeviceProfileAlarm; import org.thingsboard.server.common.data.device.profile.DeviceProfileData; import org.thingsboard.server.common.data.device.profile.DurationAlarmConditionSpec; import org.thingsboard.server.common.data.device.profile.RepeatingAlarmConditionSpec; +import org.thingsboard.server.common.data.device.profile.CustomTimeSchedule; +import org.thingsboard.server.common.data.device.profile.CustomTimeScheduleItem; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.QueueId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.query.BooleanFilterPredicate; @@ -71,6 +74,7 @@ import java.math.RoundingMode; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.ArrayList; import java.util.Optional; import java.util.TreeMap; import java.util.UUID; @@ -1086,6 +1090,179 @@ public class TbDeviceProfileNodeTest { verify(ctx, Mockito.never()).tellFailure(Mockito.any(), Mockito.any()); } + @Test + public void testActiveAlarmScheduleFromDynamicValuesWhenDefaultScheduleIsInactive() throws Exception { + init(); + + DeviceProfile deviceProfile = new DeviceProfile(); + deviceProfile.setId(deviceProfileId); + DeviceProfileData deviceProfileData = new DeviceProfileData(); + + Device device = new Device(); + device.setId(deviceId); + device.setCustomerId(customerId); + + AttributeKvCompositeKey compositeKeyActiveSchedule = new AttributeKvCompositeKey( + EntityType.TENANT, deviceId.getId(), "SERVER_SCOPE", "dynamicValueActiveSchedule" + ); + + AttributeKvEntity attributeKvEntityActiveSchedule = new AttributeKvEntity(); + attributeKvEntityActiveSchedule.setId(compositeKeyActiveSchedule); + attributeKvEntityActiveSchedule.setJsonValue( + "{\"timezone\":\"Europe/Kiev\",\"items\":[{\"enabled\":true,\"dayOfWeek\":1,\"startsOn\":0,\"endsOn\":8.64e+7},{\"enabled\":true,\"dayOfWeek\":2,\"startsOn\":0,\"endsOn\":8.64e+7},{\"enabled\":true,\"dayOfWeek\":3,\"startsOn\":0,\"endsOn\":8.64e+7},{\"enabled\":true,\"dayOfWeek\":4,\"startsOn\":0,\"endsOn\":8.64e+7},{\"enabled\":true,\"dayOfWeek\":5,\"startsOn\":0,\"endsOn\":8.64e+7},{\"enabled\":true,\"dayOfWeek\":6,\"startsOn\":8.64e+7,\"endsOn\":8.64e+7},{\"enabled\":true,\"dayOfWeek\":7,\"startsOn\":0,\"endsOn\":8.64e+7}],\"dynamicValue\":null}" + ); + attributeKvEntityActiveSchedule.setLastUpdateTs(0L); + + AttributeKvEntry entryActiveSchedule = attributeKvEntityActiveSchedule.toData(); + + ListenableFuture> listListenableFutureActiveSchedule = + Futures.immediateFuture(Collections.singletonList(entryActiveSchedule)); + + AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); + highTempFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.TIME_SERIES, "temperature")); + highTempFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate highTemperaturePredicate = new NumericFilterPredicate(); + highTemperaturePredicate.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + highTemperaturePredicate.setValue(new FilterPredicateValue<>( + 0.0, + null, + null + )); + highTempFilter.setPredicate(highTemperaturePredicate); + AlarmCondition alarmCondition = new AlarmCondition(); + alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + + CustomTimeSchedule schedule = new CustomTimeSchedule(); + schedule.setItems(Collections.emptyList()); + schedule.setDynamicValue(new DynamicValue<>(DynamicValueSourceType.CURRENT_DEVICE, "dynamicValueActiveSchedule", false)); + + AlarmRule alarmRule = new AlarmRule(); + alarmRule.setCondition(alarmCondition); + alarmRule.setSchedule(schedule); + DeviceProfileAlarm deviceProfileAlarmActiveSchedule = new DeviceProfileAlarm(); + deviceProfileAlarmActiveSchedule.setId("highTemperatureAlarmID"); + deviceProfileAlarmActiveSchedule.setAlarmType("highTemperatureAlarm"); + deviceProfileAlarmActiveSchedule.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + + deviceProfileData.setAlarms(Collections.singletonList(deviceProfileAlarmActiveSchedule)); + deviceProfile.setProfileData(deviceProfileData); + + Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) + .thenReturn(Futures.immediateFuture(Collections.emptyList())); + Mockito.when(alarmService.findLatestByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) + .thenReturn(Futures.immediateFuture(null)); + Mockito.when(alarmService.createOrUpdateAlarm(Mockito.any())).thenAnswer(AdditionalAnswers.returnsFirstArg()); + Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); + Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.anyString(), Mockito.anySet())) + .thenReturn(listListenableFutureActiveSchedule); + + TbMsg theMsg = TbMsg.newMsg("ALARM", deviceId, new TbMsgMetaData(), ""); + Mockito.when(ctx.newMsg(Mockito.any(), Mockito.anyString(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.anyString())) + .thenReturn(theMsg); + + ObjectNode data = mapper.createObjectNode(); + data.put("temperature", 35); + TbMsg msg = TbMsg.newMsg(SessionMsgType.POST_TELEMETRY_REQUEST.name(), deviceId, new TbMsgMetaData(), + TbMsgDataType.JSON, mapper.writeValueAsString(data), null, null); + + node.onMsg(ctx, msg); + verify(ctx).tellSuccess(msg); + verify(ctx).enqueueForTellNext(theMsg, "Alarm Created"); + verify(ctx, Mockito.never()).tellFailure(Mockito.any(), Mockito.any()); + } + + @Test + public void testInactiveAlarmScheduleFromDynamicValuesWhenDefaultScheduleIsActive() throws Exception { + init(); + + DeviceProfile deviceProfile = new DeviceProfile(); + deviceProfile.setId(deviceProfileId); + DeviceProfileData deviceProfileData = new DeviceProfileData(); + + Device device = new Device(); + device.setId(deviceId); + device.setCustomerId(customerId); + + AttributeKvCompositeKey compositeKeyInactiveSchedule = new AttributeKvCompositeKey( + EntityType.TENANT, deviceId.getId(), "SERVER_SCOPE", "dynamicValueInactiveSchedule" + ); + + AttributeKvEntity attributeKvEntityInactiveSchedule = new AttributeKvEntity(); + attributeKvEntityInactiveSchedule.setId(compositeKeyInactiveSchedule); + attributeKvEntityInactiveSchedule.setJsonValue( + "{\"timezone\":\"Europe/Kiev\",\"items\":[{\"enabled\":false,\"dayOfWeek\":1,\"startsOn\":0,\"endsOn\":0},{\"enabled\":false,\"dayOfWeek\":2,\"startsOn\":0,\"endsOn\":0},{\"enabled\":false,\"dayOfWeek\":3,\"startsOn\":0,\"endsOn\":0},{\"enabled\":false,\"dayOfWeek\":4,\"startsOn\":0,\"endsOn\":0},{\"enabled\":false,\"dayOfWeek\":5,\"startsOn\":0,\"endsOn\":0},{\"enabled\":false,\"dayOfWeek\":6,\"startsOn\":0,\"endsOn\":0},{\"enabled\":false,\"dayOfWeek\":7,\"startsOn\":0,\"endsOn\":0}],\"dynamicValue\":null}" + ); + + attributeKvEntityInactiveSchedule.setLastUpdateTs(0L); + + AttributeKvEntry entryInactiveSchedule = attributeKvEntityInactiveSchedule.toData(); + + ListenableFuture> listListenableFutureInactiveSchedule = + Futures.immediateFuture(Collections.singletonList(entryInactiveSchedule)); + + AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); + highTempFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.TIME_SERIES, "temperature")); + highTempFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate highTemperaturePredicate = new NumericFilterPredicate(); + highTemperaturePredicate.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + highTemperaturePredicate.setValue(new FilterPredicateValue<>( + 0.0, + null, + null + )); + + highTempFilter.setPredicate(highTemperaturePredicate); + AlarmCondition alarmCondition = new AlarmCondition(); + alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + + CustomTimeSchedule schedule = new CustomTimeSchedule(); + + List items = new ArrayList<>(); + for (int i = 0; i < 7; i++) { + CustomTimeScheduleItem item = new CustomTimeScheduleItem(); + item.setEnabled(true); + item.setDayOfWeek(i + 1); + item.setEndsOn(0); + item.setStartsOn(0); + items.add(item); + } + + schedule.setItems(items); + schedule.setDynamicValue(new DynamicValue<>(DynamicValueSourceType.CURRENT_DEVICE, "dynamicValueInactiveSchedule", false)); + + AlarmRule alarmRule = new AlarmRule(); + alarmRule.setCondition(alarmCondition); + alarmRule.setSchedule(schedule); + DeviceProfileAlarm deviceProfileAlarmNonactiveSchedule = new DeviceProfileAlarm(); + deviceProfileAlarmNonactiveSchedule.setId("highTemperatureAlarmID"); + deviceProfileAlarmNonactiveSchedule.setAlarmType("highTemperatureAlarm"); + deviceProfileAlarmNonactiveSchedule.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + + deviceProfileData.setAlarms(Collections.singletonList(deviceProfileAlarmNonactiveSchedule)); + deviceProfile.setProfileData(deviceProfileData); + + Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) + .thenReturn(Futures.immediateFuture(Collections.emptyList())); + Mockito.when(alarmService.findLatestByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) + .thenReturn(Futures.immediateFuture(null)); + Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); + Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.anyString(), Mockito.anySet())) + .thenReturn(listListenableFutureInactiveSchedule); + + TbMsg theMsg = TbMsg.newMsg("ALARM", deviceId, new TbMsgMetaData(), ""); + + ObjectNode data = mapper.createObjectNode(); + data.put("temperature", 35); + TbMsg msg = TbMsg.newMsg(SessionMsgType.POST_TELEMETRY_REQUEST.name(), deviceId, new TbMsgMetaData(), + TbMsgDataType.JSON, mapper.writeValueAsString(data), null, null); + + node.onMsg(ctx, msg); + verify(ctx).tellSuccess(msg); + verify(ctx, Mockito.never()).enqueueForTellNext(theMsg, "Alarm Created"); + verify(ctx, Mockito.never()).tellFailure(Mockito.any(), Mockito.any()); + } @Test public void testCurrentCustomersAttributeForDynamicValue() throws Exception { diff --git a/ui-ngx/src/app/core/auth/auth.service.ts b/ui-ngx/src/app/core/auth/auth.service.ts index e95dfb5485..c7b1a283d3 100644 --- a/ui-ngx/src/app/core/auth/auth.service.ts +++ b/ui-ngx/src/app/core/auth/auth.service.ts @@ -23,7 +23,7 @@ import { catchError, map, mergeMap, tap } from 'rxjs/operators'; import { LoginRequest, LoginResponse, PublicLoginRequest } from '@shared/models/login.models'; import { ActivatedRoute, Router, UrlTree } from '@angular/router'; -import { defaultHttpOptions } from '../http/http-utils'; +import { defaultHttpOptions, defaultHttpOptionsFromConfig, RequestConfig } from '../http/http-utils'; import { UserService } from '../http/user.service'; import { Store } from '@ngrx/store'; import { AppState } from '../core.state'; @@ -47,6 +47,7 @@ import { AlertDialogComponent } from '@shared/components/dialog/alert-dialog.com import { OAuth2ClientInfo, PlatformType } from '@shared/models/oauth2.models'; import { isMobileApp } from '@core/utils'; import { TwoFactorAuthProviderType, TwoFaProviderInfo } from '@shared/models/two-factor-auth.models'; +import { UserPasswordPolicy } from '@shared/models/settings.models'; @Injectable({ providedIn: 'root' @@ -163,14 +164,18 @@ export class AuthService { )); } - public changePassword(currentPassword: string, newPassword: string) { - return this.http.post('/api/auth/changePassword', {currentPassword, newPassword}, defaultHttpOptions()).pipe( + public changePassword(currentPassword: string, newPassword: string, config?: RequestConfig) { + return this.http.post('/api/auth/changePassword', {currentPassword, newPassword}, defaultHttpOptionsFromConfig(config)).pipe( tap((loginResponse: LoginResponse) => { this.setUserFromJwtToken(loginResponse.token, loginResponse.refreshToken, false); } )); } + public getUserPasswordPolicy() { + return this.http.get(`/api/noauth/userPasswordPolicy`, defaultHttpOptions()); + } + public activateByEmailCode(emailCode: string): Observable { return this.http.post(`/api/noauth/activateByEmailCode?emailCode=${emailCode}`, null, defaultHttpOptions()); diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts index f594f06855..4ebaccb774 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts @@ -1135,6 +1135,13 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC editWidget($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget) { $event.stopPropagation(); + + if (this.isAddingWidget) { + this.onAddWidgetClosed(); + this.isAddingWidgetClosed = true; + this.isEditingWidgetClosed = false; + } + if (this.editingWidgetOriginal === widget) { this.onEditWidgetClosed(); } else { diff --git a/ui-ngx/src/app/modules/home/components/edge/edge-downlink-table-config.ts b/ui-ngx/src/app/modules/home/components/edge/edge-downlink-table-config.ts index 2ddc6637b3..2e3b55f32d 100644 --- a/ui-ngx/src/app/modules/home/components/edge/edge-downlink-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/edge/edge-downlink-table-config.ts @@ -138,7 +138,7 @@ export class EdgeDownlinkTableConfig extends EntityTableConfig { - this.paginator.pageIndex = Number(params.page) || 0; - this.paginator.pageSize = Number(params.pageSize) || this.defaultPageSize; + if (this.displayPagination) { + this.paginator.pageIndex = Number(params.page) || 0; + this.paginator.pageSize = Number(params.pageSize) || this.defaultPageSize; + } this.sort.active = params.property || this.entitiesTableConfig.defaultSortOrder.property; this.sort.direction = (params.direction || this.entitiesTableConfig.defaultSortOrder.direction).toLowerCase() as SortDirection; if (params.hasOwnProperty('textSearch') && !isEmptyStr(params.textSearch)) { diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index 153a8d9759..d195137509 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -148,6 +148,7 @@ import { HOME_COMPONENTS_MODULE_TOKEN } from '@home/components/tokens'; import { DashboardStateComponent } from '@home/components/dashboard-page/dashboard-state.component'; +import { AlarmDynamicValue } from '@home/components/profile/alarm/alarm-dynamic-value.component'; import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; import { TenantProfileQueuesComponent } from '@home/components/profile/queue/tenant-profile-queues.component'; import { QueueFormComponent } from '@home/components/queue/queue-form.component'; @@ -265,6 +266,7 @@ import { AutoCommitSettingsComponent } from '@home/components/vc/auto-commit-set AlarmScheduleInfoComponent, DeviceProfileProvisionConfigurationComponent, AlarmScheduleComponent, + AlarmDynamicValue, AlarmDurationPredicateValueComponent, DeviceWizardDialogComponent, AlarmScheduleDialogComponent, @@ -394,11 +396,11 @@ import { AutoCommitSettingsComponent } from '@home/components/vc/auto-commit-set DeviceWizardDialogComponent, AlarmScheduleInfoComponent, AlarmScheduleComponent, + AlarmDynamicValue, AlarmScheduleDialogComponent, AlarmDurationPredicateValueComponent, EditAlarmDetailsDialogComponent, DeviceProfileProvisionConfigurationComponent, - AlarmScheduleComponent, SmsProviderConfigurationComponent, AwsSnsProviderConfigurationComponent, SmppSmsProviderConfigurationComponent, diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-dynamic-value.component.html b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-dynamic-value.component.html new file mode 100644 index 0000000000..6a64ba4800 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-dynamic-value.component.html @@ -0,0 +1,55 @@ + + + +
+ +
+ {{'filter.dynamic-value' | translate}} +
+
+ +
+
+ +
+
+
+ + + + + {{'filter.no-dynamic-value' | translate}} + + + {{dynamicValueSourceTypeTranslations.get(sourceType) | translate}} + + + +
+
+ + + + +
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-dynamic-value.component.ts b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-dynamic-value.component.ts new file mode 100644 index 0000000000..95084db163 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-dynamic-value.component.ts @@ -0,0 +1,99 @@ +/// +/// Copyright © 2016-2022 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. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALUE_ACCESSOR, +} from '@angular/forms'; +import { + DynamicValueSourceType, + dynamicValueSourceTypeTranslationMap, + getDynamicSourcesForAllowUser +} from '@shared/models/query/query.models'; + +@Component({ + selector: 'tb-alarm-dynamic-value', + templateUrl: './alarm-dynamic-value.component.html', + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmDynamicValue), + multi: true + }] +}) + +export class AlarmDynamicValue implements ControlValueAccessor, OnInit{ + public dynamicValue: FormGroup; + public dynamicValueSourceTypes: DynamicValueSourceType[] = getDynamicSourcesForAllowUser(false); + public dynamicValueSourceTypeTranslations = dynamicValueSourceTypeTranslationMap; + private propagateChange = (v: any) => { }; + + @Input() + helpId: string; + + @Input() + disabled: boolean; + + constructor(private fb: FormBuilder) { + } + + ngOnInit(): void { + this.dynamicValue = this.fb.group({ + sourceType: [null, []], + sourceAttribute: [null] + }) + + this.dynamicValue.get('sourceType').valueChanges.subscribe( + (sourceType) => { + if (!sourceType) { + this.dynamicValue.get('sourceAttribute').patchValue(null, {emitEvent: false}); + } + } + ); + + this.dynamicValue.valueChanges.subscribe(() => { + this.updateModel(); + }) + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + writeValue(dynamicValue: {sourceType: string, sourceAttribute: string}): void { + if(dynamicValue) { + this.dynamicValue.patchValue(dynamicValue, {emitEvent: false}); + } + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.dynamicValue.disable({emitEvent: false}); + } else { + this.dynamicValue.enable({emitEvent: false}); + } + } + + private updateModel() { + this.propagateChange(this.dynamicValue.value); + } +} diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.html b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.html index 834736a7e9..65fe9c9636 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.html @@ -34,6 +34,7 @@ formControlName="timezone">
+
device-profile.schedule-days
@@ -73,8 +74,8 @@
+
device-profile.schedule-days
-
diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.ts b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.ts index 182198ef80..f8d2e96bd1 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.ts @@ -64,7 +64,6 @@ export class AlarmScheduleComponent implements ControlValueAccessor, Validator, alarmScheduleTypes = Object.keys(AlarmScheduleType); alarmScheduleType = AlarmScheduleType; alarmScheduleTypeTranslate = AlarmScheduleTypeTranslationMap; - dayOfWeekTranslationsArray = dayOfWeekTranslations; allDays = Array(7).fill(0).map((x, i) => i); @@ -91,8 +90,10 @@ export class AlarmScheduleComponent implements ControlValueAccessor, Validator, daysOfWeek: this.fb.array(new Array(7).fill(false), this.validateDayOfWeeks), startsOn: [0, Validators.required], endsOn: [0, Validators.required], - items: this.fb.array(Array.from({length: 7}, (value, i) => this.defaultItemsScheduler(i)), this.validateItems) + items: this.fb.array(Array.from({length: 7}, (value, i) => this.defaultItemsScheduler(i)), this.validateItems), + dynamicValue: [null] }); + this.alarmScheduleForm.get('type').valueChanges.subscribe((type) => { const defaultTimezone = getDefaultTimezone(); this.alarmScheduleForm.reset({type, items: this.defaultItems, timezone: defaultTimezone}, {emitEvent: false}); @@ -158,7 +159,8 @@ export class AlarmScheduleComponent implements ControlValueAccessor, Validator, timezone: this.modelValue.timezone, daysOfWeek, startsOn: utcTimestampToTimeOfDay(this.modelValue.startsOn), - endsOn: utcTimestampToTimeOfDay(this.modelValue.endsOn) + endsOn: utcTimestampToTimeOfDay(this.modelValue.endsOn), + dynamicValue: this.modelValue.dynamicValue }, {emitEvent: false}); break; case AlarmScheduleType.CUSTOM: @@ -177,7 +179,8 @@ export class AlarmScheduleComponent implements ControlValueAccessor, Validator, this.alarmScheduleForm.patchValue({ type: this.modelValue.type, timezone: this.modelValue.timezone, - items: alarmDays + items: alarmDays, + dynamicValue: this.modelValue.dynamicValue }, {emitEvent: false}); } break; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.html index 0e396d1b07..58b7d7880c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.html @@ -15,8 +15,8 @@ limitations under the License. --> -
-
+
+
{{ value }} diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.html b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.html index 34a41eff9e..865f23a5f0 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.html +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.html @@ -66,6 +66,16 @@ {{'dashboard.delete' | translate }}
+
+ +
-
- -

profile.change-password

- - -
- - -
-
- - profile.current-password - - lock - - - - login.new-password - - lock - - - - login.new-password-again - - lock - - -
-
- - -
-
diff --git a/ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.scss deleted file mode 100644 index 2d8406456a..0000000000 --- a/ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.scss +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright © 2016-2022 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. - */ -:host { -} diff --git a/ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.ts deleted file mode 100644 index 782bcb0f4d..0000000000 --- a/ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.ts +++ /dev/null @@ -1,70 +0,0 @@ -/// -/// Copyright © 2016-2022 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. -/// - -import { Component, OnInit } from '@angular/core'; -import { MatDialogRef } from '@angular/material/dialog'; -import { Store } from '@ngrx/store'; -import { AppState } from '@core/core.state'; -import { FormBuilder, FormGroup } from '@angular/forms'; -import { ActionNotificationShow } from '@core/notification/notification.actions'; -import { TranslateService } from '@ngx-translate/core'; -import { AuthService } from '@core/auth/auth.service'; -import { DialogComponent } from '@shared/components/dialog.component'; -import { Router } from '@angular/router'; - -@Component({ - selector: 'tb-change-password-dialog', - templateUrl: './change-password-dialog.component.html', - styleUrls: ['./change-password-dialog.component.scss'] -}) -export class ChangePasswordDialogComponent extends DialogComponent implements OnInit { - - changePassword: FormGroup; - - constructor(protected store: Store, - protected router: Router, - private translate: TranslateService, - private authService: AuthService, - public dialogRef: MatDialogRef, - public fb: FormBuilder) { - super(store, router, dialogRef); - } - - ngOnInit(): void { - this.buildChangePasswordForm(); - } - - buildChangePasswordForm() { - this.changePassword = this.fb.group({ - currentPassword: [''], - newPassword: [''], - newPassword2: [''] - }); - } - - onChangePassword(): void { - if (this.changePassword.get('newPassword').value !== this.changePassword.get('newPassword2').value) { - this.store.dispatch(new ActionNotificationShow({ message: this.translate.instant('login.passwords-mismatch-error'), - type: 'error' })); - } else { - this.authService.changePassword( - this.changePassword.get('currentPassword').value, - this.changePassword.get('newPassword').value).subscribe(() => { - this.dialogRef.close(true); - }); - } - } -} diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html index 7300f7ea7c..3a15616055 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html @@ -78,24 +78,6 @@ {{ 'dashboard.home-dashboard-hide-toolbar' | translate }}
-
-
- -
-
- -
{{ expirationJwtData }}
-
-
-
{{ expirationJwtData }}
+ + +
+
+
+

profile.change-password

+ + profile.current-password + + + + {{ 'security.password-requirement.incorrect-password-try-again' | translate }} + + + + login.new-password + + + + {{ 'security.password-requirement.password-not-meet-requirements' | translate }} + + + {{ changePassword.get('newPassword').getError('alreadyUsed') }} + + + {{ 'security.password-requirement.password-should-difference' | translate }} + + + {{ 'security.password-requirement.password-should-not-contain-spaces' | translate }} + + +
+ +
+ + login.new-password-again + + + + {{ 'security.password-requirement.new-passwords-not-match' | translate }} + + +
+ +
+ +
+
+ +
+

security.password-requirement.password-requirements

+

security.password-requirement.at-least

+

+ + {{ 'security.password-requirement.uppercase-letter' | translate : {count: passwordPolicy.minimumUppercaseLetters} }} +

+

+ + {{ 'security.password-requirement.lowercase-letter' | translate : {count: passwordPolicy.minimumLowercaseLetters} }} +

+

+ + {{ 'security.password-requirement.digit' | translate : {count: passwordPolicy.minimumDigits} }} +

+

+ + {{ 'security.password-requirement.special-character' | translate : {count: passwordPolicy.minimumSpecialCharacters} }} +

+

+ + {{ 'security.password-requirement.character' | translate : {count: passwordPolicy.minimumLength} }} +

+
+
+
+ + +
+
+
+
- admin.2fa.2fa + admin.2fa.2fa
security.2fa.2fa-description
diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.scss b/ui-ngx/src/app/modules/home/pages/security/security.component.scss index 7dad927038..b593be3a2a 100644 --- a/ui-ngx/src/app/modules/home/pages/security/security.component.scss +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.scss @@ -21,33 +21,65 @@ } mat-card.profile-card { + padding: 24px; @media #{$mat-gt-sm} { - width: 70%; + width: 80%; } @media #{$mat-gt-md} { - width: 50%; + width: 55%; } @media #{$mat-gt-xl} { width: 45%; } - .mat-subheader { - line-height: 24px; - color: rgba(0, 0, 0, 0.54); + .card-title { + font: 500 18px / 24px Roboto, "Helvetica Neue", sans-serif; + letter-spacing: 0.15px; + margin-top: 0; + } + + .mat-h4 { + font-weight: 500; font-size: 14px; - font-weight: 400; + letter-spacing: 0.25px; + margin: 0 0 4px; + } + + .change-password { + margin: 0; + + .mat-divider.mat-divider-vertical { + margin-bottom: 25px; + } + + .mat-form-field { + margin-bottom: 4px; + } + + .password-requirements > p { + margin: 0 0 8px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + + .mat-icon[data-mat-icon-name="check"] { + color: #24A148; + } } - .profile-last-login-ts { - font-size: 16px; - font-weight: 400; + .auth-title { + font-weight: 500; + margin: 0; } - .profile-btn-subtext { + .token-text { font: 400 14px / 16px Roboto, "Helvetica Neue", sans-serif; letter-spacing: 0.25px; - opacity: 0.6; padding: 8px 0; + + > .date { + opacity: .7; + } } } @@ -91,3 +123,8 @@ } } } +:host ::ng-deep { + .mat-form-field-appearance-fill .mat-form-field-underline::before { + background-color: transparent; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.ts b/ui-ngx/src/app/modules/home/pages/security/security.component.ts index ab7d470e9e..1ecfb66954 100644 --- a/ui-ngx/src/app/modules/home/pages/security/security.component.ts +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.ts @@ -19,7 +19,15 @@ import { User } from '@shared/models/user.model'; import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { FormBuilder, FormGroup } from '@angular/forms'; +import { + AbstractControl, + FormBuilder, + FormGroup, FormGroupDirective, + NgForm, + ValidationErrors, + ValidatorFn, + Validators +} from '@angular/forms'; import { TranslateService } from '@ngx-translate/core'; import { MatDialog } from '@angular/material/dialog'; import { DialogService } from '@core/services/dialog.service'; @@ -29,6 +37,7 @@ import { DatePipe } from '@angular/common'; import { ClipboardService } from 'ngx-clipboard'; import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; import { + AccountTwoFaSettingProviders, AccountTwoFaSettings, BackupCodeTwoFactorAuthAccountConfig, EmailTwoFactorAuthAccountConfig, @@ -39,7 +48,9 @@ import { import { authenticationDialogMap } from '@home/pages/security/authentication-dialog/authentication-dialog.map'; import { takeUntil, tap } from 'rxjs/operators'; import { Observable, of, Subject } from 'rxjs'; -import { isDefinedAndNotNull } from '@core/utils'; +import { isDefinedAndNotNull, isEqual } from '@core/utils'; +import { AuthService } from '@core/auth/auth.service'; +import { UserPasswordPolicy } from '@shared/models/settings.models'; @Component({ selector: 'tb-security', @@ -49,10 +60,14 @@ import { isDefinedAndNotNull } from '@core/utils'; export class SecurityComponent extends PageComponent implements OnInit, OnDestroy { private readonly destroy$ = new Subject(); - private accountConfig: AccountTwoFaSettings; + private accountConfig: AccountTwoFaSettingProviders; twoFactorAuth: FormGroup; + changePassword: FormGroup; + user: User; + passwordPolicy: UserPasswordPolicy; + allowTwoFactorProviders: TwoFactorAuthProviderType[] = []; providersData = twoFactorAuthProvidersData; twoFactorAuthProviderType = TwoFactorAuthProviderType; @@ -80,6 +95,7 @@ export class SecurityComponent extends PageComponent implements OnInit, OnDestro public dialogService: DialogService, public fb: FormBuilder, private datePipe: DatePipe, + private authService: AuthService, private clipboardService: ClipboardService) { super(store); } @@ -88,6 +104,8 @@ export class SecurityComponent extends PageComponent implements OnInit, OnDestro this.buildTwoFactorForm(); this.user = this.route.snapshot.data.user; this.twoFactorLoad(this.route.snapshot.data.providers); + this.buildChangePasswordForm(); + this.loadPasswordPolicy(); } ngOnDestroy() { @@ -128,12 +146,11 @@ export class SecurityComponent extends PageComponent implements OnInit, OnDestro } private processTwoFactorAuthConfig(setting: AccountTwoFaSettings) { - this.accountConfig = setting; - const configs = this.accountConfig.configs; + this.accountConfig = setting?.configs || {}; Object.values(TwoFactorAuthProviderType).forEach(provider => { - if (configs[provider]) { + if (this.accountConfig[provider]) { this.twoFactorAuth.get(provider).setValue(true); - if (configs[provider].useByDefault) { + if (this.accountConfig[provider].useByDefault) { this.useByDefault = provider; } } else { @@ -142,6 +159,75 @@ export class SecurityComponent extends PageComponent implements OnInit, OnDestro }); } + private buildChangePasswordForm() { + this.changePassword = this.fb.group({ + currentPassword: [''], + newPassword: ['', Validators.required], + newPassword2: ['', this.samePasswordValidation(false, 'newPassword')] + }); + } + + private loadPasswordPolicy() { + this.authService.getUserPasswordPolicy().subscribe(policy => { + this.passwordPolicy = policy; + this.changePassword.get('newPassword').setValidators([ + this.passwordStrengthValidator(), + this.samePasswordValidation(true, 'currentPassword'), + Validators.required + ]); + this.changePassword.get('newPassword').updateValueAndValidity({emitEvent: false}); + }); + } + + private passwordStrengthValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value: string = control.value; + const errors: any = {}; + + if (this.passwordPolicy.minimumUppercaseLetters > 0 && + !new RegExp(`(?:.*?[A-Z]){${this.passwordPolicy.minimumUppercaseLetters}}`).test(value)) { + errors.notUpperCase = true; + } + + if (this.passwordPolicy.minimumLowercaseLetters > 0 && + !new RegExp(`(?:.*?[a-z]){${this.passwordPolicy.minimumLowercaseLetters}}`).test(value)) { + errors.notLowerCase = true; + } + + if (this.passwordPolicy.minimumDigits > 0 + && !new RegExp(`(?:.*?\\d){${this.passwordPolicy.minimumDigits}}`).test(value)) { + errors.notNumeric = true; + } + + if (this.passwordPolicy.minimumSpecialCharacters > 0 && + !new RegExp(`(?:.*?[\\W_]){${this.passwordPolicy.minimumSpecialCharacters}}`).test(value)) { + errors.notSpecial = true; + } + + if (!this.passwordPolicy.allowWhitespaces && /\s/.test(value)) { + errors.hasWhitespaces = true; + } + + if (this.passwordPolicy.minimumLength > 0 && value.length < this.passwordPolicy.minimumLength) { + errors.minLength = true; + } + + return isEqual(errors, {}) ? null : errors; + }; + } + + private samePasswordValidation(isSame: boolean, key: string): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value: string = control.value; + const keyValue = control.parent?.value[key]; + + if (isSame) { + return value === keyValue ? {samePassword: true} : null; + } + return value !== keyValue ? {differencePassword: true} : null; + }; + } + trackByProvider(i: number, provider: TwoFactorAuthProviderType) { return provider; } @@ -216,7 +302,7 @@ export class SecurityComponent extends PageComponent implements OnInit, OnDestro } generateNewBackupCode() { - const codeLeft = this.accountConfig.configs[TwoFactorAuthProviderType.BACKUP_CODE].codesLeft; + const codeLeft = (this.accountConfig[TwoFactorAuthProviderType.BACKUP_CODE] as BackupCodeTwoFactorAuthAccountConfig).codesLeft; let subscription: Observable; if (codeLeft) { subscription = this.dialogService.confirm( @@ -240,7 +326,7 @@ export class SecurityComponent extends PageComponent implements OnInit, OnDestro providerDataInfo(provider: TwoFactorAuthProviderType) { const info = {info: null}; - const providerConfig = this.accountConfig.configs[provider]; + const providerConfig = this.accountConfig[provider]; if (isDefinedAndNotNull(providerConfig)) { switch (provider) { case TwoFactorAuthProviderType.EMAIL: @@ -256,4 +342,41 @@ export class SecurityComponent extends PageComponent implements OnInit, OnDestro } return info; } + + onChangePassword(form: FormGroupDirective): void { + if (this.changePassword.valid) { + this.authService.changePassword(this.changePassword.get('currentPassword').value, + this.changePassword.get('newPassword').value, {ignoreErrors: true}).subscribe(() => { + this.discardChanges(form); + }, + (error) => { + if (error.status === 400 && error.error.message === 'Current password doesn\'t match!') { + this.changePassword.get('currentPassword').setErrors({differencePassword: true}); + } else if (error.status === 400 && error.error.message.startsWith('Password must')) { + this.loadPasswordPolicy(); + } else if (error.status === 400 && error.error.message.startsWith('Password was already used')) { + this.changePassword.get('newPassword').setErrors({alreadyUsed: error.error.message}); + } else { + this.store.dispatch(new ActionNotificationShow({ + message: error.error.message, + type: 'error', + target: 'changePassword' + })); + } + }); + } else { + this.changePassword.markAllAsTouched(); + } + } + + discardChanges(form: FormGroupDirective, event?: MouseEvent) { + if (event) { + event.stopPropagation(); + } + form.resetForm({ + currentPassword: '', + newPassword: '', + newPassword2: '' + }); + } } diff --git a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.ts b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.ts index 73401f2a0c..2a98f0b530 100644 --- a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.ts +++ b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.ts @@ -29,6 +29,7 @@ import { import { TranslateService } from '@ngx-translate/core'; import { interval, Subscription } from 'rxjs'; import { isEqual } from '@core/utils'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; @Component({ selector: 'tb-two-factor-auth-login', @@ -118,6 +119,13 @@ export class TwoFactorAuthLoginComponent extends PageComponent implements OnInit } this.verificationForm.get('verificationCode').setErrors(errors); }, 5000); + } else { + this.store.dispatch(new ActionNotificationShow({ + message: error.error.message, + type: 'error', + verticalPosition: 'top', + horizontalPosition: 'left' + })); } } ); diff --git a/ui-ngx/src/app/shared/models/device.models.ts b/ui-ngx/src/app/shared/models/device.models.ts index c4c29f5da6..88a2ccf16e 100644 --- a/ui-ngx/src/app/shared/models/device.models.ts +++ b/ui-ngx/src/app/shared/models/device.models.ts @@ -480,6 +480,10 @@ export const AlarmScheduleTypeTranslationMap = new Map +
  • +timezone: this value is used to designate the timezone you are using. +
  • +
  • +items: the array of values representing the days on which the schedule will be active. +
  • + + +One array item contains such fields: +
      +
    • +dayOfWeek: this value is used to designate the specified day in numerical representation (Monday - 1, Tuesday 2, etc.) on which the schedule will be active. +
    • +
    • +enabled: this boolean value, used to designate that the specified day in the schedule will be enabled. +
    • +
    • +startsOn: this value is used to designate the timestamp in milliseconds, from which the schedule will be active for the designated day. +
    • +
    • +endsOn: this value is used to designate the timestamp in milliseconds until which the schedule will be active for the specified day. +
    • +
    +When startsOn and endsOn equals 0 it's means that the schedule will be active the whole day. diff --git a/ui-ngx/src/assets/help/en_US/device-profile/alarm_specific_schedule_format.md b/ui-ngx/src/assets/help/en_US/device-profile/alarm_specific_schedule_format.md new file mode 100644 index 0000000000..cbf6b3a3ea --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/device-profile/alarm_specific_schedule_format.md @@ -0,0 +1,31 @@ +#### Specific schedule format + +An attribute with a dynamic value for a specific schedule format must have JSON in the following format: + +```javascript +{ + "daysOfWeek": [ + 2, + 4 + ], + "endsOn": 0, + "startsOn": 0, + "timezone": "Europe/Kiev" +} +``` + +
      +
    • +timezone: this value is used to designate the timezone you are using. +
    • +
    • +daysOfWeek: this value is used to designate the days in numerical representation (Monday - 1, Tuesday 2, etc.) on which the schedule will be active. +
    • +
    • +startsOn: this value is used to designate the timestamp in milliseconds, from which the schedule will be active for the designated days. +
    • +
    • +endsOn: this value is used to designate the timestamp in milliseconds until which the schedule will be active for the specified days. +
    • +
    +When startsOn and endsOn equals 0 it's means that the schedule will be active the whole day. diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 19cd88b1a5..263ddf891b 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -963,6 +963,8 @@ "assignedToCustomer": "Assigned to customer", "assignedToCustomers": "Assigned to customers", "public": "Public", + "copyId": "Copy dashboard id", + "idCopiedMessage": "Dashboard Id has been copied to clipboard", "public-link": "Public link", "copy-public-link": "Copy public link", "public-link-copied-message": "Dashboard public link has been copied to clipboard", @@ -2275,6 +2277,7 @@ "current-device": "Current device", "default-value": "Default value", "dynamic-source-type": "Dynamic source type", + "dynamic-value": "Dynamic value", "no-dynamic-value": "No dynamic value", "source-attribute": "Source attribute", "switch-to-dynamic-value": "Switch to dynamic value", @@ -2527,7 +2530,7 @@ "password-reset": "Password reset", "expired-password-reset-message": "Your credentials has been expired! Please create new password.", "new-password": "New password", - "new-password-again": "New password again", + "new-password-again": "Confirm new password", "password-link-sent-message": "Reset link has been sent", "email": "Email", "login-with": "Login with {{name}}", @@ -2630,12 +2633,13 @@ "change-password": "Change Password", "current-password": "Current password", "copy-jwt-token": "Copy JWT token", - "valid-till": "Valid till {{expirationData}}", + "jwt-token": "JWT token", + "token-valid-till": "Token is valid till", "tokenCopiedSuccessMessage": "JWT token has been copied to clipboard", "tokenCopiedWarnMessage": "JWT token is expired! Please, refresh the page." }, "security": { - "security": "Security", + "security": "Password and authentication", "2fa": { "2fa": "Two-factor authentication", "2fa-description": "Two-factor authentication protects your account from unauthorized access. All you have to do is enter a security code when you log in.", @@ -2689,6 +2693,20 @@ "backup-code-description": "These printable one-time passcodes allow you to sign in when away from your phone, like when you’re traveling.", "backup-code-hint": "{{ info }} single-use codes are active at this time" } + }, + "password-requirement": { + "at-least": "At least:", + "character": "{ count, plural, 1 {1 character} other {# characters} }", + "digit": "{ count, plural, 1 {1 digit} other {# digits} }", + "incorrect-password-try-again": "Incorrect password. Try again", + "lowercase-letter": "{ count, plural, 1 {1 lowercase letter} other {# lowercase letters} }", + "new-passwords-not-match": "New password didn't match", + "password-should-not-contain-spaces": "Your password should not contain spaces", + "password-not-meet-requirements": "Password didn't meet requirements", + "password-requirements": "Password requirements", + "password-should-difference": "New password should be different from current", + "special-character": "{ count, plural, 1 {1 special character} other {# special characters} }", + "uppercase-letter": "{ count, plural, 1 {1 uppercase letter} other {# uppercase letters} }" } }, "relation": { diff --git a/ui-ngx/src/assets/locale/locale.constant-ru_RU.json b/ui-ngx/src/assets/locale/locale.constant-ru_RU.json index a4e894e59e..e9d5587d4d 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ru_RU.json +++ b/ui-ngx/src/assets/locale/locale.constant-ru_RU.json @@ -483,6 +483,8 @@ "add-widget": "Добавить новый виджет", "title": "Название", "select-widget-title": "Выберите виджет", + "copyId": "Копировать идентификатор дашборда", + "idCopiedMessage": "Идентификатор дашборда скопирован в буфер обмена", "select-widget-subtitle": "Список доступных виджетов", "delete": "Удалить дашборд", "title-required": "Название обязательно.", @@ -1289,7 +1291,6 @@ "change-password": "Изменить пароль", "current-password": "Текущий пароль", "copy-jwt-token": "Копировать JWT токен", - "valid-till": "Действителен до {{expirationData}}", "tokenCopiedMessage": "JWT токен скопирован в буфер обмена", "tokenCopiedWarnMessage": "JWT токен недействителен! Перезагрузите страницу." }, diff --git a/ui-ngx/src/assets/locale/locale.constant-uk_UA.json b/ui-ngx/src/assets/locale/locale.constant-uk_UA.json index eff83577fd..a361d1cab4 100644 --- a/ui-ngx/src/assets/locale/locale.constant-uk_UA.json +++ b/ui-ngx/src/assets/locale/locale.constant-uk_UA.json @@ -594,6 +594,8 @@ "add-widget": "Додати новий віджет", "title": "Назва", "select-widget-title": "Вибрати віджет", + "copyId": "Копіювати ідентифікатор панелі приладів", + "idCopiedMessage": "Ідентифікатор панелі приладів скопійовано в буфер обміну", "select-widget-subtitle": "Список доступних типів віджетів", "delete": "Видалити панель приладів", "title-required": "Необхідно задати назву.", @@ -1704,7 +1706,6 @@ "change-password": "Змінити пароль", "current-password": "Поточний пароль", "copy-jwt-token": "Копіювати JWT токен", - "valid-till": "Дійсний до {{expirationData}}", "tokenCopiedMessage": "JWT токен скопійовано в буфер обміну", "tokenCopiedWarnMessage": "JWT токен не є дійсним! Перезавантажте сторінку." }, diff --git a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json index 6d8aa21ca1..9efaf09b41 100644 --- a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json +++ b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json @@ -2184,7 +2184,6 @@ "last-login-time": "最后登录", "profile": "属性", "copy-jwt-token": "复制 JWT 令牌", - "valid-till": "有效期至 {{expirationData}}", "tokenCopiedSuccessMessage": "JWT 令牌已复制到剪贴板", "tokenCopiedWarnMessage": "JWT 令牌已过期!请刷新页面。" }, diff --git a/ui-ngx/src/theme.scss b/ui-ngx/src/theme.scss index e0aac57674..cdf4d611cd 100644 --- a/ui-ngx/src/theme.scss +++ b/ui-ngx/src/theme.scss @@ -221,6 +221,7 @@ $tb-dark-theme: get-tb-dark-theme( @mixin tb-components-theme($theme) { $primary: map-get($theme, primary); + $warn: map-get($theme, warn); mat-toolbar{ &.mat-hue-3 { @@ -233,6 +234,12 @@ $tb-dark-theme: get-tb-dark-theme( div.tb-dashboard-page.mobile-app { @include mat-fab-toolbar-inverse-theme($tb-theme); } + + .same-color.mat-form-field-invalid { + .mat-form-field-suffix { + color: mat.get-color-from-palette($warn, text); + } + } } .tb-default {