Browse Source

Merge with develop/3.4

pull/6713/head
Igor Kulikov 4 years ago
parent
commit
18ec32f092
  1. 27
      application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java
  2. 7
      application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java
  3. 6
      application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java
  4. 3
      common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmSchedule.java
  5. 7
      common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AnyTimeSchedule.java
  6. 3
      common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeSchedule.java
  7. 4
      common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SpecificTimeSchedule.java
  8. 2
      common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java
  9. 4
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java
  10. 4
      dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java
  11. 41
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmRuleState.java
  12. 15
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/ProfileState.java
  13. 177
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeTest.java
  14. 11
      ui-ngx/src/app/core/auth/auth.service.ts
  15. 7
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts
  16. 2
      ui-ngx/src/app/modules/home/components/edge/edge-downlink-table-config.ts
  17. 6
      ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts
  18. 4
      ui-ngx/src/app/modules/home/components/home-components.module.ts
  19. 55
      ui-ngx/src/app/modules/home/components/profile/alarm/alarm-dynamic-value.component.html
  20. 99
      ui-ngx/src/app/modules/home/components/profile/alarm/alarm-dynamic-value.component.ts
  21. 3
      ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.html
  22. 11
      ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.ts
  23. 4
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.html
  24. 10
      ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.html
  25. 11
      ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.ts
  26. 15
      ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.scss
  27. 64
      ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.html
  28. 17
      ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.scss
  29. 70
      ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.ts
  30. 18
      ui-ngx/src/app/modules/home/pages/profile/profile.component.html
  31. 6
      ui-ngx/src/app/modules/home/pages/profile/profile.component.scss
  32. 55
      ui-ngx/src/app/modules/home/pages/profile/profile.component.ts
  33. 4
      ui-ngx/src/app/modules/home/pages/profile/profile.module.ts
  34. 115
      ui-ngx/src/app/modules/home/pages/security/security.component.html
  35. 59
      ui-ngx/src/app/modules/home/pages/security/security.component.scss
  36. 141
      ui-ngx/src/app/modules/home/pages/security/security.component.ts
  37. 8
      ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.ts
  38. 4
      ui-ngx/src/app/shared/models/device.models.ts
  39. 7
      ui-ngx/src/app/shared/models/two-factor-auth.models.ts
  40. 79
      ui-ngx/src/assets/help/en_US/device-profile/alarm_custom_schedule_format.md
  41. 31
      ui-ngx/src/assets/help/en_US/device-profile/alarm_specific_schedule_format.md
  42. 24
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  43. 3
      ui-ngx/src/assets/locale/locale.constant-ru_RU.json
  44. 3
      ui-ngx/src/assets/locale/locale.constant-uk_UA.json
  45. 1
      ui-ngx/src/assets/locale/locale.constant-zh_CN.json
  46. 7
      ui-ngx/src/theme.scss

27
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<AccountTwoFaSettings> 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<TwoFaProviderType, TwoFaAccountConfig> 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;
});
}

7
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());
}

6
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();

3
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<String> getDynamicValue();
}

7
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<String> getDynamicValue() {
return null;
}
}

3
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<CustomTimeScheduleItem> items;
private DynamicValue<String> dynamicValue;
@Override
public AlarmScheduleType getType() {
return AlarmScheduleType.CUSTOM;

4
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<String> dynamicValue;
@Override
public AlarmScheduleType getType() {
return AlarmScheduleType.SPECIFIC_TIME;

2
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<TwoFaProviderConfig> 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;

4
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> T parse(String json, Class<T> clazz) {
return fromJson(parse(json), clazz);
}
public static String toJson(JsonElement element) {
return GSON.toJson(element);
}

4
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);
});

41
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;

15
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<String> 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) {

177
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<List<AttributeKvEntry>> 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<List<AttributeKvEntry>> 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<CustomTimeScheduleItem> 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 {

11
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<UserPasswordPolicy>(`/api/noauth/userPasswordPolicy`, defaultHttpOptions());
}
public activateByEmailCode(emailCode: string): Observable<LoginResponse> {
return this.http.post<LoginResponse>(`/api/noauth/activateByEmailCode?emailCode=${emailCode}`,
null, defaultHttpOptions());

7
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 {

2
ui-ngx/src/app/modules/home/components/edge/edge-downlink-table-config.ts

@ -138,7 +138,7 @@ export class EdgeDownlinkTableConfig extends EntityTableConfig<EdgeEvent, TimePa
}
private updateEdgeEventStatus(createdTime: number): string {
if (this.queueStartTs && createdTime < this.queueStartTs) {
if (this.queueStartTs && createdTime <= this.queueStartTs) {
return this.translate.instant('edge.deployed');
} else {
return this.translate.instant('edge.pending');

6
ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts

@ -295,8 +295,10 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa
if (this.pageMode) {
this.route.queryParams.pipe(skip(1)).subscribe((params: PageQueryParam) => {
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)) {

4
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,

55
ui-ngx/src/app/modules/home/components/profile/alarm/alarm-dynamic-value.component.html

@ -0,0 +1,55 @@
<!--
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.
-->
<mat-expansion-panel [formGroup] = "dynamicValue" class="device-profile-alarm" style = "margin-bottom: 26px;" fxFlex>
<mat-expansion-panel-header>
<div fxFlex fxLayout="row" fxLayoutAlign="start center">
<mat-panel-title>
<div fxLayout="row" fxFlex fxLayoutAlign="start center">
{{'filter.dynamic-value' | translate}}
</div>
</mat-panel-title>
<span fxFlex></span>
</div>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div fxFlex fxLayout="column">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div fxFlex="40" fxLayout="column">
<mat-form-field floatLabel="always" hideRequiredMarker class="mat-block">
<mat-label></mat-label>
<mat-select formControlName="sourceType" placeholder="{{'filter.dynamic-source-type' | translate}}">
<mat-option [value]="null">
{{'filter.no-dynamic-value' | translate}}
</mat-option>
<mat-option *ngFor="let sourceType of dynamicValueSourceTypes" [value]="sourceType">
{{dynamicValueSourceTypeTranslations.get(sourceType) | translate}}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div fxFlex fxLayout="column">
<mat-form-field floatLabel="always" hideRequiredMarker class="mat-block source-attribute">
<mat-label></mat-label>
<input matInput formControlName="sourceAttribute" placeholder="{{'filter.source-attribute' | translate}}">
</mat-form-field>
</div>
<div [tb-help-popup]="helpId"></div>
</div>
</div>
</ng-template>
</mat-expansion-panel>

99
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);
}
}

3
ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.html

@ -34,6 +34,7 @@
formControlName="timezone">
</tb-timezone-select>
<section *ngIf="alarmScheduleForm.get('type').value === alarmScheduleType.SPECIFIC_TIME">
<tb-alarm-dynamic-value formControlName = 'dynamicValue' helpId = 'device-profile/alarm_specific_schedule_format'></tb-alarm-dynamic-value>
<div class="tb-small" style="margin-bottom: 0.5em" translate>device-profile.schedule-days</div>
<div fxLayout="column" fxLayout.gt-md="row" fxLayoutGap="16px">
<div fxLayout="row" fxLayoutGap="16px">
@ -73,8 +74,8 @@
</div>
</section>
<section *ngIf="alarmScheduleForm.get('type').value === alarmScheduleType.CUSTOM">
<tb-alarm-dynamic-value formControlName = 'dynamicValue' helpId = 'device-profile/alarm_custom_schedule_format'></tb-alarm-dynamic-value>
<div class="tb-small" style="margin-bottom: 0.5em" translate>device-profile.schedule-days</div>
<div *ngFor="let day of allDays" fxLayout="column" formArrayName="items" fxLayoutGap="1em">
<div fxLayout.xs="column" fxLayout="row" fxLayoutGap="8px" [formGroupName]="''+day" fxLayoutAlign="start center" fxLayoutAlign.xs="center start">
<mat-checkbox formControlName="enabled" fxFlex="17" (change)="changeCustomScheduler($event, day)">

11
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;

4
ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.html

@ -15,8 +15,8 @@
limitations under the License.
-->
<div class="tb-knob" fxLayout="column" [ngStyle]="{'pointerEvents': ctx.isEdit ? 'none' : 'all'}">
<div #knobContainer id="knob-container" fxFlex fxLayout="column" fxLayoutAlign="center center">
<div #knobContainer class="tb-knob" fxLayout="column" [ngStyle]="{'pointerEvents': ctx.isEdit ? 'none' : 'all'}">
<div fxFlex fxLayout="column" fxLayoutAlign="center center">
<div #knob class="knob">
<div #knobValueContainer class="value-container" fxLayout="row" fxLayoutAlign="center center">
<span #knobValue class="knob-value">{{ value }}</span>

10
ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.html

@ -66,6 +66,16 @@
{{'dashboard.delete' | translate }}
</button>
</div>
<div fxLayout="row">
<button mat-raised-button
ngxClipboard
(cbOnSuccess)="onDashboardIdCopied($event)"
[cbContent]="entity?.id?.id"
[fxShow]="!isEdit">
<mat-icon svgIcon="mdi:clipboard-arrow-left"></mat-icon>
<span translate>dashboard.copyId</span>
</button>
</div>
<div class="mat-padding" fxLayout="column">
<mat-form-field class="mat-block"
[fxShow]="!isEdit && assignedCustomersText?.length

11
ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.ts

@ -119,6 +119,17 @@ export class DashboardFormComponent extends EntityComponent<Dashboard> {
}));
}
onDashboardIdCopied($event) {
this.store.dispatch(new ActionNotificationShow(
{
message: this.translate.instant('dashboard.idCopiedMessage'),
type: 'success',
duration: 750,
verticalPosition: 'bottom',
horizontalPosition: 'right'
}));
}
private updateFields(entity: Dashboard): void {
if (entity && !isEqual(entity, {})) {
this.assignedCustomersText = getDashboardAssignedCustomersText(entity);

15
ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.scss

@ -1,15 +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.
*/

64
ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.html

@ -1,64 +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.
-->
<form [formGroup]="changePassword" (ngSubmit)="onChangePassword()">
<mat-toolbar fxLayout="row" color="primary">
<h2 translate>profile.change-password</h2>
<span fxFlex></span>
<button mat-icon-button
[mat-dialog-close]="false"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content>
<mat-form-field class="mat-block">
<mat-label translate>profile.current-password</mat-label>
<input matInput type="password" formControlName="currentPassword"/>
<mat-icon class="material-icons" matPrefix>lock</mat-icon>
<tb-toggle-password matSuffix></tb-toggle-password>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>login.new-password</mat-label>
<input matInput type="password" formControlName="newPassword"/>
<mat-icon class="material-icons" matPrefix>lock</mat-icon>
<tb-toggle-password matSuffix></tb-toggle-password>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>login.new-password-again</mat-label>
<input matInput type="password" formControlName="newPassword2"/>
<mat-icon class="material-icons" matPrefix>lock</mat-icon>
<tb-toggle-password matSuffix></tb-toggle-password>
</mat-form-field>
</div>
<div mat-dialog-actions fxLayout="row" fxLayoutAlign="end center">
<button mat-button color="primary"
type="button"
[disabled]="(isLoading$ | async)"
[mat-dialog-close]="false" cdkFocusInitial>
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button color="primary"
type="submit"
[disabled]="(isLoading$ | async) || changePassword.invalid">
{{ 'profile.change-password' | translate }}
</button>
</div>
</form>

17
ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.scss

@ -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 {
}

70
ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.ts

@ -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<ChangePasswordDialogComponent> implements OnInit {
changePassword: FormGroup;
constructor(protected store: Store<AppState>,
protected router: Router,
private translate: TranslateService,
private authService: AuthService,
public dialogRef: MatDialogRef<ChangePasswordDialogComponent>,
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);
});
}
}
}

18
ui-ngx/src/app/modules/home/pages/profile/profile.component.html

@ -78,24 +78,6 @@
{{ 'dashboard.home-dashboard-hide-toolbar' | translate }}
</mat-checkbox>
</section>
<div fxLayout="row" fxLayoutGap="16px" style="padding-bottom: 16px; margin-top: 20px;">
<div>
<button mat-button mat-raised-button color="primary"
type="button"
[disabled]="(isLoading$ | async)" (click)="changePassword()">
{{'profile.change-password' | translate}}
</button>
</div>
<div>
<button mat-raised-button
type="button"
(click)="copyToken()">
<mat-icon svgIcon="mdi:clipboard-arrow-left"></mat-icon>
<span>{{ 'profile.copy-jwt-token' | translate }}</span>
</button>
<div class="profile-btn-subtext">{{ expirationJwtData }}</div>
</div>
</div>
<div fxLayout="row" fxLayoutAlign="end start">
<button mat-button mat-raised-button color="primary"
type="submit"

6
ui-ngx/src/app/modules/home/pages/profile/profile.component.scss

@ -38,12 +38,6 @@
font-size: 16px;
font-weight: 400;
}
.profile-btn-subtext {
font: 400 14px / 16px Roboto, "Helvetica Neue", sans-serif;
letter-spacing: 0.25px;
opacity: 0.6;
padding: 8px 0;
}
.tb-home-dashboard {
tb-dashboard-autocomplete {
@media #{$mat-gt-sm} {

55
ui-ngx/src/app/modules/home/pages/profile/profile.component.ts

@ -27,16 +27,9 @@ import { ActionAuthUpdateUserDetails } from '@core/auth/auth.actions';
import { environment as env } from '@env/environment';
import { TranslateService } from '@ngx-translate/core';
import { ActionSettingsChangeLanguage } from '@core/settings/settings.actions';
import { ChangePasswordDialogComponent } from '@modules/home/pages/profile/change-password-dialog.component';
import { MatDialog } from '@angular/material/dialog';
import { DialogService } from '@core/services/dialog.service';
import { AuthService } from '@core/auth/auth.service';
import { ActivatedRoute } from '@angular/router';
import { isDefinedAndNotNull } from '@core/utils';
import { getCurrentAuthUser } from '@core/auth/auth.selectors';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { DatePipe } from '@angular/common';
import { ClipboardService } from 'ngx-clipboard';
@Component({
selector: 'tb-profile',
@ -51,29 +44,11 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir
languageList = env.supportedLangs;
private readonly authUser: AuthUser;
get jwtToken(): string {
return `Bearer ${localStorage.getItem('jwt_token')}`;
}
get jwtTokenExpiration(): string {
return localStorage.getItem('jwt_token_expiration');
}
get expirationJwtData(): string {
const expirationData = this.datePipe.transform(this.jwtTokenExpiration, 'yyyy-MM-dd HH:mm:ss');
return this.translate.instant('profile.valid-till', { expirationData });
}
constructor(protected store: Store<AppState>,
private route: ActivatedRoute,
private userService: UserService,
private authService: AuthService,
private translate: TranslateService,
public dialog: MatDialog,
public dialogService: DialogService,
public fb: FormBuilder,
private datePipe: DatePipe,
private clipboardService: ClipboardService) {
public fb: FormBuilder) {
super(store);
this.authUser = getCurrentAuthUser(this.store);
}
@ -121,13 +96,6 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir
);
}
changePassword(): void {
this.dialog.open(ChangePasswordDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog']
});
}
private userLoaded(user: User) {
this.user = user;
this.profile.reset(user);
@ -158,25 +126,4 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir
isSysAdmin(): boolean {
return this.authUser.authority === Authority.SYS_ADMIN;
}
copyToken() {
if (+this.jwtTokenExpiration < Date.now()) {
this.store.dispatch(new ActionNotificationShow({
message: this.translate.instant('profile.tokenCopiedWarnMessage'),
type: 'warn',
duration: 1500,
verticalPosition: 'bottom',
horizontalPosition: 'right'
}));
} else {
this.clipboardService.copyFromContent(this.jwtToken);
this.store.dispatch(new ActionNotificationShow({
message: this.translate.instant('profile.tokenCopiedSuccessMessage'),
type: 'success',
duration: 750,
verticalPosition: 'bottom',
horizontalPosition: 'right'
}));
}
}
}

4
ui-ngx/src/app/modules/home/pages/profile/profile.module.ts

@ -19,12 +19,10 @@ import { CommonModule } from '@angular/common';
import { ProfileComponent } from './profile.component';
import { SharedModule } from '@shared/shared.module';
import { ProfileRoutingModule } from './profile-routing.module';
import { ChangePasswordDialogComponent } from '@modules/home/pages/profile/change-password-dialog.component';
@NgModule({
declarations: [
ProfileComponent,
ChangePasswordDialogComponent
ProfileComponent
],
imports: [
CommonModule,

115
ui-ngx/src/app/modules/home/pages/security/security.component.html

@ -17,34 +17,117 @@
-->
<div class="profile-container" fxLayout="column" fxLayoutGap="8px">
<mat-card class="profile-card" fxLayout="column">
<mat-card-title>
<div fxLayout="row" fxLayout.xs="column" fxLayoutGap.xs="8px"
fxLayoutAlign="space-between start" fxLayoutAlign.xs="start start">
<div fxFlex class="mat-headline" translate>
security.security
</div>
<div fxLayout="column">
<span class="mat-subheader" translate>profile.last-login-time</span>
<span class="profile-last-login-ts" style='opacity: 0.7;'>{{ user?.additionalInfo?.lastLoginTs | date:'yyyy-MM-dd HH:mm:ss' }}</span>
</div>
</div>
<mat-card-title style="margin-bottom: 8px;">
<span class="mat-headline card-title" translate>profile.jwt-token</span>
</mat-card-title>
<mat-card-content>
<div>
<button mat-stroked-button
<div fxLayout="row" fxLayoutAlign="space-between center">
<div class="token-text">{{ 'profile.token-valid-till' | translate }} <span class="date">{{ jwtTokenExpiration | date: 'yyyy-MM-dd HH:mm:ss' }}</span></div>
<button mat-raised-button
color="primary"
type="button"
(click)="copyToken()">
<mat-icon class="material-icons">add_circle_outline</mat-icon>
<span>{{ 'profile.copy-jwt-token' | translate }}</span>
</button>
<div class="profile-btn-subtext">{{ expirationJwtData }}</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="profile-card" fxLayout="column">
<mat-card-content class="change-password" tb-toast toastTarget="changePassword">
<form #changePasswordForm="ngForm" [formGroup]="changePassword" (ngSubmit)="onChangePassword(changePasswordForm)">
<div fxLayout="row" fxLayout.xs="column" fxLayoutGap="25px" fxLayoutGap.xs="0">
<div fxFlex="290px" fxFlex.sm="250px" fxFlex.xs="100">
<h3 class="card-title" translate>profile.change-password</h3>
<mat-form-field class="mat-block same-color" hideRequiredMarker appearance="fill" color="primary">
<mat-label translate>profile.current-password</mat-label>
<input matInput type="password" name="current-password" formControlName="currentPassword" autocomplete="current-password" required/>
<tb-toggle-password [fxShow]="changePassword.get('currentPassword').dirty || changePassword.get('currentPassword').touched" matSuffix></tb-toggle-password>
<mat-error *ngIf="changePassword.get('currentPassword').hasError('differencePassword')">
{{ 'security.password-requirement.incorrect-password-try-again' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block same-color" hideRequiredMarker appearance="fill" color="primary">
<mat-label translate>login.new-password</mat-label>
<input matInput type="password" name="new-password" formControlName="newPassword" autocomplete="new-password" required/>
<tb-toggle-password [fxShow]="changePassword.get('newPassword').dirty || changePassword.get('newPassword').touched" matSuffix></tb-toggle-password>
<mat-error *ngIf="changePassword.get('newPassword').errors
&& !changePassword.get('newPassword').hasError('alreadyUsed')
&& !changePassword.get('newPassword').hasError('hasWhitespaces')
&& !changePassword.get('newPassword').hasError('samePassword')">
{{ 'security.password-requirement.password-not-meet-requirements' | translate }}
</mat-error>
<mat-error *ngIf="changePassword.get('newPassword').hasError('alreadyUsed')">
{{ changePassword.get('newPassword').getError('alreadyUsed') }}
</mat-error>
<mat-error *ngIf="changePassword.get('newPassword').hasError('samePassword')">
{{ 'security.password-requirement.password-should-difference' | translate }}
</mat-error>
<mat-error *ngIf="changePassword.get('newPassword').hasError('hasWhitespaces')">
{{ 'security.password-requirement.password-should-not-contain-spaces' | translate }}
</mat-error>
</mat-form-field>
<div fxFlex fxHide fxShow.xs fxLayoutAlign="start center">
<ng-container *ngTemplateOutlet="passwordRequirements"></ng-container>
</div>
<mat-form-field class="mat-block same-color" hideRequiredMarker appearance="fill" color="primary">
<mat-label translate>login.new-password-again</mat-label>
<input matInput type="password" name="new-password" formControlName="newPassword2" autocomplete="new-password" required/>
<tb-toggle-password [fxShow]="changePassword.get('newPassword2').dirty || changePassword.get('newPassword2').touched" matSuffix></tb-toggle-password>
<mat-error *ngIf="changePassword.get('newPassword2').hasError('differencePassword')">
{{ 'security.password-requirement.new-passwords-not-match' | translate }}
</mat-error>
</mat-form-field>
</div>
<mat-divider [vertical]="true"></mat-divider>
<div fxFlex fxHide.xs fxLayoutAlign="start start">
<ng-container *ngTemplateOutlet="passwordRequirements"></ng-container>
</div>
</div>
<ng-template #passwordRequirements>
<div class="password-requirements" *ngIf="passwordPolicy">
<h3 class="card-title" translate>security.password-requirement.password-requirements</h3>
<h4 class="mat-h4" translate>security.password-requirement.at-least</h4>
<p class="mat-body" *ngIf="passwordPolicy.minimumUppercaseLetters > 0">
<mat-icon class="tb-mat-20" [svgIcon]="changePassword.get('newPassword').hasError('notUpperCase') ? 'mdi:circle-small' : 'mdi:check'"></mat-icon>
{{ 'security.password-requirement.uppercase-letter' | translate : {count: passwordPolicy.minimumUppercaseLetters} }}
</p>
<p class="mat-body" *ngIf="passwordPolicy.minimumLowercaseLetters > 0">
<mat-icon class="tb-mat-20" [svgIcon]="changePassword.get('newPassword').hasError('notLowerCase') ? 'mdi:circle-small' : 'mdi:check'"></mat-icon>
{{ 'security.password-requirement.lowercase-letter' | translate : {count: passwordPolicy.minimumLowercaseLetters} }}
</p>
<p class="mat-body" *ngIf="passwordPolicy.minimumDigits > 0">
<mat-icon class="tb-mat-20" [svgIcon]="changePassword.get('newPassword').hasError('notNumeric') ? 'mdi:circle-small' : 'mdi:check'"></mat-icon>
{{ 'security.password-requirement.digit' | translate : {count: passwordPolicy.minimumDigits} }}
</p>
<p class="mat-body" *ngIf="passwordPolicy.minimumSpecialCharacters > 0">
<mat-icon class="tb-mat-20" [svgIcon]="changePassword.get('newPassword').hasError('notSpecial') ? 'mdi:circle-small' : 'mdi:check'"></mat-icon>
{{ 'security.password-requirement.special-character' | translate : {count: passwordPolicy.minimumSpecialCharacters} }}
</p>
<p class="mat-body" *ngIf="passwordPolicy.minimumLength > 0">
<mat-icon class="tb-mat-20" [svgIcon]="changePassword.get('newPassword').hasError('minLength') ? 'mdi:circle-small' : 'mdi:check'"></mat-icon>
{{ 'security.password-requirement.character' | translate : {count: passwordPolicy.minimumLength} }}
</p>
</div>
</ng-template>
<div fxLayout="row" fxLayoutGap="8px" style="margin-top: 18px;" [fxShow]="changePassword.dirty || changePassword.touched">
<button mat-button color="primary"
type="button"
(click)="discardChanges(changePasswordForm, $event)"
[disabled]="(isLoading$ | async)">
{{ 'action.discard-changes' | translate }}
</button>
<button mat-raised-button color="primary"
type="submit"
[disabled]="(isLoading$ | async)">
{{ 'profile.change-password' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
<mat-card class="profile-card" *ngIf="allowTwoFactorProviders.length">
<mat-card-title style="margin-bottom: 20px;">
<span class="mat-headline" translate>admin.2fa.2fa</span>
<span class="mat-headline card-title" translate>admin.2fa.2fa</span>
</mat-card-title>
<mat-card-subtitle style="margin-bottom: 40px;">
<div class="mat-body-1 description" translate>security.2fa.2fa-description</div>

59
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;
}
}

141
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<void>();
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<boolean>;
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: ''
});
}
}

8
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'
}));
}
}
);

4
ui-ngx/src/app/shared/models/device.models.ts

@ -480,6 +480,10 @@ export const AlarmScheduleTypeTranslationMap = new Map<AlarmScheduleType, string
);
export interface AlarmSchedule{
dynamicValue?: {
sourceAttribute: string,
sourceType: string;
};
type: AlarmScheduleType;
timezone?: string;
daysOfWeek?: number[];

7
ui-ngx/src/app/shared/models/two-factor-auth.models.ts

@ -88,11 +88,14 @@ export interface BackupCodeTwoFactorAuthAccountConfig extends GeneralTwoFactorAu
export type TwoFactorAuthAccountConfig = TotpTwoFactorAuthAccountConfig | SmsTwoFactorAuthAccountConfig |
EmailTwoFactorAuthAccountConfig | BackupCodeTwoFactorAuthAccountConfig;
export interface AccountTwoFaSettings {
configs: {TwoFactorAuthProviderType: TwoFactorAuthAccountConfig};
configs: AccountTwoFaSettingProviders;
}
export type AccountTwoFaSettingProviders = {
[key in TwoFactorAuthProviderType]?: TwoFactorAuthAccountConfig;
};
export interface TwoFaProviderInfo {
type: TwoFactorAuthProviderType;
default: boolean;

79
ui-ngx/src/assets/help/en_US/device-profile/alarm_custom_schedule_format.md

@ -0,0 +1,79 @@
#### Custom schedule format
An attribute with a dynamic value for a custom schedule format must have JSON in the following format:
```javascript
{
"timezone": "Europe/Kiev",
"items": [
{
"dayOfWeek": 1,
"enabled": true,
"endsOn": 0,
"startsOn": 0
},
{
"dayOfWeek": 2,
"enabled": true,
"endsOn": 0,
"startsOn": 0
},
{
"dayOfWeek": 3,
"enabled": true,
"endsOn": 0,
"startsOn": 0
},
{
"dayOfWeek": 4,
"enabled": true,
"endsOn": 0,
"startsOn": 0
},
{
"dayOfWeek": 5,
"enabled": true,
"endsOn": 0,
"startsOn": 0
},
{
"dayOfWeek": 6,
"enabled": true,
"endsOn": 0,
"startsOn": 0
},
{
"dayOfWeek": 7,
"enabled": true,
"endsOn": 0,
"startsOn": 0
}
]
}
```
<ul>
<li>
<b>timezone:</b> this value is used to designate the timezone you are using.
</li>
<li>
<b>items:</b> the array of values representing the days on which the schedule will be active.
</li>
</ul>
One array item contains such fields:
<ul>
<li>
<b>dayOfWeek:</b> this value is used to designate the specified day in numerical representation (Monday - 1, Tuesday 2, etc.) on which the schedule will be active.
</li>
<li>
<b>enabled:</b> this <code>boolean</code> value, used to designate that the specified day in the schedule will be enabled.
</li>
<li>
<b>startsOn:</b> this value is used to designate the timestamp in milliseconds, from which the schedule will be active for the designated day.
</li>
<li>
<b>endsOn:</b> this value is used to designate the timestamp in milliseconds until which the schedule will be active for the specified day.
</li>
</ul>
When <b>startsOn</b> and <b>endsOn</b> equals 0 it's means that the schedule will be active the whole day.

31
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"
}
```
<ul>
<li>
<b>timezone:</b> this value is used to designate the timezone you are using.
</li>
<li>
<b>daysOfWeek:</b> this value is used to designate the days in numerical representation (Monday - 1, Tuesday 2, etc.) on which the schedule will be active.
</li>
<li>
<b>startsOn:</b> this value is used to designate the timestamp in milliseconds, from which the schedule will be active for the designated days.
</li>
<li>
<b>endsOn:</b> this value is used to designate the timestamp in milliseconds until which the schedule will be active for the specified days.
</li>
</ul>
When <b>startsOn</b> and <b>endsOn</b> equals 0 it's means that the schedule will be active the whole day.

24
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": {

3
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 токен недействителен! Перезагрузите страницу."
},

3
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 токен не є дійсним! Перезавантажте сторінку."
},

1
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 令牌已过期!请刷新页面。"
},

7
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 {

Loading…
Cancel
Save