Browse Source

Merge branch 'master' into task/4114-gateway-version-compatibility

pull/11516/head
Max Petrov 2 years ago
committed by GitHub
parent
commit
18a56bf38e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 7
      application/src/main/data/json/system/scada_symbols/sand-filter.svg
  2. 74
      application/src/main/data/json/system/widget_bundles/scada_fluid_system.json
  3. 2
      application/src/main/data/json/system/widget_bundles/scada_symbols.json
  4. 74
      application/src/main/data/json/system/widget_bundles/scada_water_system_symbols.json
  5. 2
      application/src/main/data/json/system/widget_types/scada_symbol.json
  6. 19
      application/src/main/data/upgrade/3.7.0/schema_update.sql
  7. 6
      application/src/main/java/org/thingsboard/server/controller/AdminController.java
  8. 126
      application/src/main/java/org/thingsboard/server/controller/AuthController.java
  9. 25
      application/src/main/java/org/thingsboard/server/controller/BaseController.java
  10. 2
      application/src/main/java/org/thingsboard/server/controller/ImageController.java
  11. 3
      application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java
  12. 69
      application/src/main/java/org/thingsboard/server/controller/UserController.java
  13. 13
      application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java
  14. 2
      application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java
  15. 32
      application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java
  16. 5
      application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java
  17. 1
      application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java
  18. 11
      application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java
  19. 2
      application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java
  20. 10
      application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretServiceImpl.java
  21. 8
      application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java
  22. 82
      application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java
  23. 6
      application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java
  24. 2
      application/src/main/resources/templates/activation.ftl
  25. 2
      application/src/main/resources/templates/reset.password.ftl
  26. 5
      application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
  27. 184
      application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java
  28. 25
      application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java
  29. 18
      application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java
  30. 12
      application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java
  31. 4
      common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java
  32. 19
      common/data/src/main/java/org/thingsboard/server/common/data/UserActivationLink.java
  33. 104
      common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java
  34. 25
      common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java
  35. 19
      common/queue/src/main/java/org/thingsboard/server/queue/scheduler/DefaultSchedulerComponent.java
  36. 95
      common/queue/src/test/java/org/thingsboard/server/queue/scheduler/DefaultSchedulerComponentTest.java
  37. 97
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/activity/AbstractActivityManager.java
  38. 2
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/activity/strategy/FirstAndLastEventActivityStrategy.java
  39. 2
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/activity/strategy/FirstEventActivityStrategy.java
  40. 10
      common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java
  41. 4
      dao/src/main/java/org/thingsboard/server/dao/edge/BaseRelatedEdgesService.java
  42. 2
      dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java
  43. 10
      dao/src/main/java/org/thingsboard/server/dao/model/sql/UserCredentialsEntity.java
  44. 6
      dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java
  45. 81
      dao/src/main/java/org/thingsboard/server/dao/settings/DefaultSecuritySettingsService.java
  46. 26
      dao/src/main/java/org/thingsboard/server/dao/settings/SecuritySettingsService.java
  47. 29
      dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java
  48. 2
      dao/src/main/resources/sql/schema-entities.sql
  49. 2
      dao/src/test/java/org/thingsboard/server/dao/sql/user/JpaUserCredentialsDaoTest.java
  50. 6
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MailService.java
  51. 6
      ui-ngx/src/app/core/http/user.service.ts
  52. 45
      ui-ngx/src/app/core/services/dynamic-component-factory.service.ts
  53. 6
      ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html
  54. 2
      ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.ts
  55. 6
      ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.html
  56. 2
      ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.ts
  57. 6
      ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.html
  58. 2
      ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.ts
  59. 6
      ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.html
  60. 2
      ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.ts
  61. 54
      ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html
  62. 13
      ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts
  63. 10
      ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.models.ts
  64. 2
      ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.html
  65. 25
      ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.ts
  66. 4
      ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.models.ts
  67. 2
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html
  68. 13
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts
  69. 4
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.models.ts
  70. 18
      ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts
  71. 6
      ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.html
  72. 3
      ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.ts
  73. 6
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html
  74. 3
      ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.ts
  75. 6
      ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.html
  76. 3
      ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.ts
  77. 6
      ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.html
  78. 3
      ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.ts
  79. 2
      ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.html
  80. 6
      ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.ts
  81. 5
      ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.models.ts
  82. 38
      ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html
  83. 3
      ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts
  84. 1
      ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.html
  85. 2
      ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html
  86. 18
      ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.ts
  87. 8
      ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts
  88. 6
      ui-ngx/src/app/modules/home/pages/user/users-table-config.resolver.ts
  89. 1
      ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html
  90. 4
      ui-ngx/src/app/modules/home/pages/widget/widget-library.module.ts
  91. 53
      ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.html
  92. 22
      ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.scss
  93. 78
      ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.ts
  94. 2
      ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html
  95. 9
      ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.ts
  96. 20
      ui-ngx/src/app/modules/login/login-routing.module.ts
  97. 4
      ui-ngx/src/app/modules/login/login.module.ts
  98. 40
      ui-ngx/src/app/modules/login/pages/login/link-expired.component.html
  99. 32
      ui-ngx/src/app/modules/login/pages/login/link-expired.component.scss
  100. 47
      ui-ngx/src/app/modules/login/pages/login/link-expired.component.ts

7
application/src/main/data/json/system/scada_symbols/stand-filter.svg → application/src/main/data/json/system/scada_symbols/sand-filter.svg

@ -1,9 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:tb="https://thingsboard.io/svg" width="600" height="1e3" fill="none" version="1.1" viewBox="0 0 600 1e3"> <svg xmlns="http://www.w3.org/2000/svg" xmlns:tb="https://thingsboard.io/svg" width="600" height="1e3" fill="none" version="1.1" viewBox="0 0 600 1e3">
<tb:metadata xmlns=""><![CDATA[{ <tb:metadata xmlns=""><![CDATA[{
"title": "Stand filter", "title": "Sand filter",
"description": "Stand filter with configurable filtration mode option and various states.", "description": "Sand filter with configurable filtration mode option and various states.",
"searchTags": [ "searchTags": [
"filter" "filter",
"sand"
], ],
"widgetSizeX": 3, "widgetSizeX": 3,
"widgetSizeY": 5, "widgetSizeY": 5,

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

74
application/src/main/data/json/system/widget_bundles/scada_fluid_system.json

File diff suppressed because one or more lines are too long

2
application/src/main/data/json/system/widget_bundles/scada_symbols.json

File diff suppressed because one or more lines are too long

74
application/src/main/data/json/system/widget_bundles/scada_water_system_symbols.json

@ -1,74 +0,0 @@
{
"widgetsBundle": {
"alias": "scada_water_system_symbols",
"title": "SCADA water system symbols",
"scada": true,
"image": null,
"description": "Bundle with SCADA symbols for water system",
"order": 9300,
"name": "SCADA water system symbols"
},
"widgetTypeFqns": [
"horizontal_pipe",
"long_horizontal_pipe",
"vertical_pipe",
"long_vertical_pipe",
"left_bottom_elbow_pipe",
"bottom_right_elbow_pipe",
"top_right_elbow_pipe",
"left_top_elbow_pipe",
"cross_pipe",
"left_tee_pipe",
"bottom_tee_pipe",
"right_tee_pipe",
"top_tee_pipe",
"right_elbow_drain_pipe",
"left_elbow_drain_pipe",
"left_drain_pipe",
"right_drain_pipe",
"short_left_drain_pipe",
"short_right_drain_pipe",
"top_flow_meter",
"right_flow_meter",
"bottom_flow_meter",
"left_flow_meter",
"horizontal_inline_flow_meter",
"vertical_inline_flow_meter",
"left_analog_water_level_meter",
"right_analog_water_level_meter",
"leak_sensor",
"centrifugal_pump",
"small_right_motor_pump",
"small_left_motor_pump",
"right_motor_pump",
"left_motor_pump",
"right_heat_pump",
"left_heat_pump",
"short_bottom_filter",
"long_bottom_filter",
"short_top_filter",
"long_top_filter",
"stand_filter",
"horizontal_wheel_valve",
"vertical_wheel_valve",
"horizontal_ball_valve",
"vertical_ball_valve",
"water_stop",
"vertical_tank",
"stand_vertical_tank",
"cylindrical_tank",
"stand_cylindrical_tank",
"vertical_short_tank",
"stand_vertical_short_tank",
"large_cylindrical_tank",
"large_stand_cylindrical_tank",
"large_vertical_tank",
"large_stand_vertical_tank",
"horizontal_tank",
"stand_horizontal_tank",
"spherical_tank",
"small_spherical_tank",
"elevated_tank",
"pool"
]
}

2
application/src/main/data/json/system/widget_types/scada_symbol.json

File diff suppressed because one or more lines are too long

19
application/src/main/data/upgrade/3.7.0/schema_update.sql

@ -195,4 +195,21 @@ $$
END END
$$; $$;
-- OAUTH2 UPDATE END -- OAUTH2 UPDATE END
-- USER CREDENTIALS UPDATE START
ALTER TABLE user_credentials ADD COLUMN IF NOT EXISTS activate_token_exp_time BIGINT;
-- Setting 24-hour TTL for existing activation tokens
UPDATE user_credentials SET activate_token_exp_time = cast(extract(EPOCH FROM NOW()) * 1000 AS BIGINT) + 86400000
WHERE activate_token IS NOT NULL AND activate_token_exp_time IS NULL;
ALTER TABLE user_credentials ADD COLUMN IF NOT EXISTS reset_token_exp_time BIGINT;
-- Setting 24-hour TTL for existing password reset tokens
UPDATE user_credentials SET reset_token_exp_time = cast(extract(EPOCH FROM NOW()) * 1000 AS BIGINT) + 86400000
WHERE reset_token IS NOT NULL AND reset_token_exp_time IS NULL;
UPDATE admin_settings SET json_value = (json_value::jsonb || '{"userActivationTokenTtl":24,"passwordResetTokenTtl":24}'::jsonb)::varchar
WHERE key = 'securitySettings';
-- USER CREDENTIALS UPDATE END

6
application/src/main/java/org/thingsboard/server/controller/AdminController.java

@ -73,6 +73,7 @@ import org.thingsboard.server.common.data.sync.vc.VcUtils;
import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.config.annotations.ApiOperation;
import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.audit.AuditLogService;
import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.dao.settings.AdminSettingsService;
import org.thingsboard.server.dao.settings.SecuritySettingsService;
import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService; import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService;
import org.thingsboard.server.service.security.auth.oauth2.CookieUtils; import org.thingsboard.server.service.security.auth.oauth2.CookieUtils;
@ -109,6 +110,7 @@ public class AdminController extends BaseController {
private final SmsService smsService; private final SmsService smsService;
private final AdminSettingsService adminSettingsService; private final AdminSettingsService adminSettingsService;
private final SystemSecurityService systemSecurityService; private final SystemSecurityService systemSecurityService;
private final SecuritySettingsService securitySettingsService;
private final JwtSettingsService jwtSettingsService; private final JwtSettingsService jwtSettingsService;
private final JwtTokenFactory tokenFactory; private final JwtTokenFactory tokenFactory;
private final EntitiesVersionControlService versionControlService; private final EntitiesVersionControlService versionControlService;
@ -167,7 +169,7 @@ public class AdminController extends BaseController {
@ResponseBody @ResponseBody
public SecuritySettings getSecuritySettings() throws ThingsboardException { public SecuritySettings getSecuritySettings() throws ThingsboardException {
accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ);
return checkNotNull(systemSecurityService.getSecuritySettings()); return checkNotNull(securitySettingsService.getSecuritySettings());
} }
@ApiOperation(value = "Update Security Settings (saveSecuritySettings)", @ApiOperation(value = "Update Security Settings (saveSecuritySettings)",
@ -179,7 +181,7 @@ public class AdminController extends BaseController {
@Parameter(description = "A JSON value representing the Security Settings.") @Parameter(description = "A JSON value representing the Security Settings.")
@RequestBody SecuritySettings securitySettings) throws ThingsboardException { @RequestBody SecuritySettings securitySettings) throws ThingsboardException {
accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.WRITE); accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.WRITE);
securitySettings = checkNotNull(systemSecurityService.saveSecuritySettings(securitySettings)); securitySettings = checkNotNull(securitySettingsService.saveSecuritySettings(securitySettings));
return securitySettings; return securitySettings;
} }

126
application/src/main/java/org/thingsboard/server/controller/AuthController.java

@ -21,17 +21,15 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.cache.limits.RateLimitService;
@ -48,6 +46,7 @@ import org.thingsboard.server.common.data.security.model.JwtPair;
import org.thingsboard.server.common.data.security.model.SecuritySettings; import org.thingsboard.server.common.data.security.model.SecuritySettings;
import org.thingsboard.server.common.data.security.model.UserPasswordPolicy; import org.thingsboard.server.common.data.security.model.UserPasswordPolicy;
import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.config.annotations.ApiOperation;
import org.thingsboard.server.dao.settings.SecuritySettingsService;
import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails;
import org.thingsboard.server.service.security.model.ActivateUserRequest; import org.thingsboard.server.service.security.model.ActivateUserRequest;
@ -59,9 +58,6 @@ import org.thingsboard.server.service.security.model.UserPrincipal;
import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import org.thingsboard.server.service.security.model.token.JwtTokenFactory;
import org.thingsboard.server.service.security.system.SystemSecurityService; import org.thingsboard.server.service.security.system.SystemSecurityService;
import java.net.URI;
import java.net.URISyntaxException;
@RestController @RestController
@TbCoreComponent @TbCoreComponent
@RequestMapping("/api") @RequestMapping("/api")
@ -75,6 +71,7 @@ public class AuthController extends BaseController {
private final JwtTokenFactory tokenFactory; private final JwtTokenFactory tokenFactory;
private final MailService mailService; private final MailService mailService;
private final SystemSecurityService systemSecurityService; private final SystemSecurityService systemSecurityService;
private final SecuritySettingsService securitySettingsService;
private final RateLimitService rateLimitService; private final RateLimitService rateLimitService;
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
@ -82,9 +79,8 @@ public class AuthController extends BaseController {
@ApiOperation(value = "Get current User (getUser)", @ApiOperation(value = "Get current User (getUser)",
notes = "Get the information about the User which credentials are used to perform this REST API call.") notes = "Get the information about the User which credentials are used to perform this REST API call.")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@RequestMapping(value = "/auth/user", method = RequestMethod.GET) @GetMapping(value = "/auth/user")
public @ResponseBody public User getUser() throws ThingsboardException {
User getUser() throws ThingsboardException {
SecurityUser securityUser = getCurrentUser(); SecurityUser securityUser = getCurrentUser();
return userService.findUserById(securityUser.getTenantId(), securityUser.getId()); return userService.findUserById(securityUser.getTenantId(), securityUser.getId());
} }
@ -92,8 +88,7 @@ public class AuthController extends BaseController {
@ApiOperation(value = "Logout (logout)", @ApiOperation(value = "Logout (logout)",
notes = "Special API call to record the 'logout' of the user to the Audit Logs. Since platform uses [JWT](https://jwt.io/), the actual logout is the procedure of clearing the [JWT](https://jwt.io/) token on the client side. ") notes = "Special API call to record the 'logout' of the user to the Audit Logs. Since platform uses [JWT](https://jwt.io/), the actual logout is the procedure of clearing the [JWT](https://jwt.io/) token on the client side. ")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@RequestMapping(value = "/auth/logout", method = RequestMethod.POST) @PostMapping(value = "/auth/logout")
@ResponseStatus(value = HttpStatus.OK)
public void logout(HttpServletRequest request) throws ThingsboardException { public void logout(HttpServletRequest request) throws ThingsboardException {
logLogoutAction(request); logLogoutAction(request);
} }
@ -101,8 +96,7 @@ public class AuthController extends BaseController {
@ApiOperation(value = "Change password for current User (changePassword)", @ApiOperation(value = "Change password for current User (changePassword)",
notes = "Change the password for the User which credentials are used to perform this REST API call. Be aware that previously generated [JWT](https://jwt.io/) tokens will be still valid until they expire.") notes = "Change the password for the User which credentials are used to perform this REST API call. Be aware that previously generated [JWT](https://jwt.io/) tokens will be still valid until they expire.")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@RequestMapping(value = "/auth/changePassword", method = RequestMethod.POST) @PostMapping(value = "/auth/changePassword")
@ResponseStatus(value = HttpStatus.OK)
public JwtPair changePassword(@Parameter(description = "Change Password Request") public JwtPair changePassword(@Parameter(description = "Change Password Request")
@RequestBody ChangePasswordRequest changePasswordRequest) throws ThingsboardException { @RequestBody ChangePasswordRequest changePasswordRequest) throws ThingsboardException {
String currentPassword = changePasswordRequest.getCurrentPassword(); String currentPassword = changePasswordRequest.getCurrentPassword();
@ -125,46 +119,34 @@ public class AuthController extends BaseController {
@ApiOperation(value = "Get the current User password policy (getUserPasswordPolicy)", @ApiOperation(value = "Get the current User password policy (getUserPasswordPolicy)",
notes = "API call to get the password policy for the password validation form(s).") notes = "API call to get the password policy for the password validation form(s).")
@RequestMapping(value = "/noauth/userPasswordPolicy", method = RequestMethod.GET) @GetMapping(value = "/noauth/userPasswordPolicy")
@ResponseBody
public UserPasswordPolicy getUserPasswordPolicy() throws ThingsboardException { public UserPasswordPolicy getUserPasswordPolicy() throws ThingsboardException {
SecuritySettings securitySettings = SecuritySettings securitySettings = checkNotNull(securitySettingsService.getSecuritySettings());
checkNotNull(systemSecurityService.getSecuritySettings());
return securitySettings.getPasswordPolicy(); return securitySettings.getPasswordPolicy();
} }
@ApiOperation(value = "Check Activate User Token (checkActivateToken)", @ApiOperation(value = "Check Activate User Token (checkActivateToken)",
notes = "Checks the activation token and forwards user to 'Create Password' page. " + notes = "Checks the activation token and forwards user to 'Create Password' page. " +
"If token is valid, returns '303 See Other' (redirect) response code with the correct address of 'Create Password' page and same 'activateToken' specified in the URL parameters. " + "If token is valid, returns '303 See Other' (redirect) response code with the correct address of 'Create Password' page and same 'activateToken' specified in the URL parameters. " +
"If token is not valid, returns '409 Conflict'.") "If token is not valid, returns '409 Conflict'. " +
@RequestMapping(value = "/noauth/activate", params = {"activateToken"}, method = RequestMethod.GET) "If token is expired, redirects to error page.")
public ResponseEntity<String> checkActivateToken( @GetMapping(value = "/noauth/activate", params = {"activateToken"})
public ResponseEntity<?> checkActivateToken(
@Parameter(description = "The activate token string.") @Parameter(description = "The activate token string.")
@RequestParam(value = "activateToken") String activateToken) { @RequestParam(value = "activateToken") String activateToken) {
HttpHeaders headers = new HttpHeaders();
HttpStatus responseStatus;
UserCredentials userCredentials = userService.findUserCredentialsByActivateToken(TenantId.SYS_TENANT_ID, activateToken); UserCredentials userCredentials = userService.findUserCredentialsByActivateToken(TenantId.SYS_TENANT_ID, activateToken);
if (userCredentials != null) { if (userCredentials == null) {
String createURI = "/login/createPassword"; return response(HttpStatus.CONFLICT);
try { } else if (userCredentials.isActivationTokenExpired()) {
URI location = new URI(createURI + "?activateToken=" + activateToken); return redirectTo("/activationLinkExpired");
headers.setLocation(location);
responseStatus = HttpStatus.SEE_OTHER;
} catch (URISyntaxException e) {
log.error("Unable to create URI with address [{}]", createURI);
responseStatus = HttpStatus.BAD_REQUEST;
}
} else {
responseStatus = HttpStatus.CONFLICT;
} }
return new ResponseEntity<>(headers, responseStatus); return redirectTo("/login/createPassword?activateToken=" + activateToken);
} }
@ApiOperation(value = "Request reset password email (requestResetPasswordByEmail)", @ApiOperation(value = "Request reset password email (requestResetPasswordByEmail)",
notes = "Request to send the reset password email if the user with specified email address is present in the database. " + notes = "Request to send the reset password email if the user with specified email address is present in the database. " +
"Always return '200 OK' status for security purposes.") "Always return '200 OK' status for security purposes.")
@RequestMapping(value = "/noauth/resetPasswordByEmail", method = RequestMethod.POST) @PostMapping(value = "/noauth/resetPasswordByEmail")
@ResponseStatus(value = HttpStatus.OK)
public void requestResetPasswordByEmail( public void requestResetPasswordByEmail(
@Parameter(description = "The JSON object representing the reset password email request.") @Parameter(description = "The JSON object representing the reset password email request.")
@RequestBody ResetPasswordEmailRequest resetPasswordByEmailRequest, @RequestBody ResetPasswordEmailRequest resetPasswordByEmailRequest,
@ -177,7 +159,7 @@ public class AuthController extends BaseController {
String resetUrl = String.format("%s/api/noauth/resetPassword?resetToken=%s", baseUrl, String resetUrl = String.format("%s/api/noauth/resetPassword?resetToken=%s", baseUrl,
userCredentials.getResetToken()); userCredentials.getResetToken());
mailService.sendResetPasswordEmailAsync(resetUrl, email); mailService.sendResetPasswordEmailAsync(resetUrl, userCredentials.getResetTokenTtl(), email);
} catch (Exception e) { } catch (Exception e) {
log.warn("Error occurred: {}", e.getMessage()); log.warn("Error occurred: {}", e.getMessage());
} }
@ -186,32 +168,22 @@ public class AuthController extends BaseController {
@ApiOperation(value = "Check password reset token (checkResetToken)", @ApiOperation(value = "Check password reset token (checkResetToken)",
notes = "Checks the password reset token and forwards user to 'Reset Password' page. " + notes = "Checks the password reset token and forwards user to 'Reset Password' page. " +
"If token is valid, returns '303 See Other' (redirect) response code with the correct address of 'Reset Password' page and same 'resetToken' specified in the URL parameters. " + "If token is valid, returns '303 See Other' (redirect) response code with the correct address of 'Reset Password' page and same 'resetToken' specified in the URL parameters. " +
"If token is not valid, returns '409 Conflict'.") "If token is not valid, returns '409 Conflict'. " +
@RequestMapping(value = "/noauth/resetPassword", params = {"resetToken"}, method = RequestMethod.GET) "If token is expired, redirects to error page.")
public ResponseEntity<String> checkResetToken( @GetMapping(value = "/noauth/resetPassword", params = {"resetToken"})
public ResponseEntity<?> checkResetToken(
@Parameter(description = "The reset token string.") @Parameter(description = "The reset token string.")
@RequestParam(value = "resetToken") String resetToken) { @RequestParam(value = "resetToken") String resetToken) {
HttpHeaders headers = new HttpHeaders();
HttpStatus responseStatus;
String resetURI = "/login/resetPassword";
UserCredentials userCredentials = userService.findUserCredentialsByResetToken(TenantId.SYS_TENANT_ID, resetToken); UserCredentials userCredentials = userService.findUserCredentialsByResetToken(TenantId.SYS_TENANT_ID, resetToken);
if (userCredentials == null) {
if (userCredentials != null) { return response(HttpStatus.CONFLICT);
if (!rateLimitService.checkRateLimit(LimitedApi.PASSWORD_RESET, userCredentials.getUserId(), defaultLimitsConfiguration)) { } else if (userCredentials.isResetTokenExpired()) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build(); return redirectTo("/passwordResetLinkExpired");
} }
try { if (!rateLimitService.checkRateLimit(LimitedApi.PASSWORD_RESET, userCredentials.getUserId(), defaultLimitsConfiguration)) {
URI location = new URI(resetURI + "?resetToken=" + resetToken); return response(HttpStatus.TOO_MANY_REQUESTS);
headers.setLocation(location);
responseStatus = HttpStatus.SEE_OTHER;
} catch (URISyntaxException e) {
log.error("Unable to create URI with address [{}]", resetURI);
responseStatus = HttpStatus.BAD_REQUEST;
}
} else {
responseStatus = HttpStatus.CONFLICT;
} }
return new ResponseEntity<>(headers, responseStatus); return redirectTo("/login/resetPassword?resetToken=" + resetToken);
} }
@ApiOperation(value = "Activate User", @ApiOperation(value = "Activate User",
@ -220,15 +192,12 @@ public class AuthController extends BaseController {
"The response already contains the [JWT](https://jwt.io) activation and refresh tokens, " + "The response already contains the [JWT](https://jwt.io) activation and refresh tokens, " +
"to simplify the user activation flow and avoid asking user to input password again after activation. " + "to simplify the user activation flow and avoid asking user to input password again after activation. " +
"If token is valid, returns the object that contains [JWT](https://jwt.io/) access and refresh tokens. " + "If token is valid, returns the object that contains [JWT](https://jwt.io/) access and refresh tokens. " +
"If token is not valid, returns '404 Bad Request'.") "If token is not valid, returns '400 Bad Request'.")
@RequestMapping(value = "/noauth/activate", method = RequestMethod.POST) @PostMapping(value = "/noauth/activate")
@ResponseStatus(value = HttpStatus.OK) public JwtPair activateUser(@Parameter(description = "Activate user request.")
@ResponseBody @RequestBody ActivateUserRequest activateRequest,
public JwtPair activateUser( @RequestParam(required = false, defaultValue = "true") boolean sendActivationMail,
@Parameter(description = "Activate user request.") HttpServletRequest request) {
@RequestBody ActivateUserRequest activateRequest,
@RequestParam(required = false, defaultValue = "true") boolean sendActivationMail,
HttpServletRequest request) throws ThingsboardException {
String activateToken = activateRequest.getActivateToken(); String activateToken = activateRequest.getActivateToken();
String password = activateRequest.getPassword(); String password = activateRequest.getPassword();
systemSecurityService.validatePassword(password, null); systemSecurityService.validatePassword(password, null);
@ -258,18 +227,18 @@ public class AuthController extends BaseController {
@ApiOperation(value = "Reset password (resetPassword)", @ApiOperation(value = "Reset password (resetPassword)",
notes = "Checks the password reset token and updates the password. " + notes = "Checks the password reset token and updates the password. " +
"If token is valid, returns the object that contains [JWT](https://jwt.io/) access and refresh tokens. " + "If token is valid, returns the object that contains [JWT](https://jwt.io/) access and refresh tokens. " +
"If token is not valid, returns '404 Bad Request'.") "If token is not valid, returns '400 Bad Request'.")
@RequestMapping(value = "/noauth/resetPassword", method = RequestMethod.POST) @PostMapping(value = "/noauth/resetPassword")
@ResponseStatus(value = HttpStatus.OK) public JwtPair resetPassword(@Parameter(description = "Reset password request.")
@ResponseBody @RequestBody ResetPasswordRequest resetPasswordRequest,
public JwtPair resetPassword( HttpServletRequest request) throws ThingsboardException {
@Parameter(description = "Reset password request.")
@RequestBody ResetPasswordRequest resetPasswordRequest,
HttpServletRequest request) throws ThingsboardException {
String resetToken = resetPasswordRequest.getResetToken(); String resetToken = resetPasswordRequest.getResetToken();
String password = resetPasswordRequest.getPassword(); String password = resetPasswordRequest.getPassword();
UserCredentials userCredentials = userService.findUserCredentialsByResetToken(TenantId.SYS_TENANT_ID, resetToken); UserCredentials userCredentials = userService.findUserCredentialsByResetToken(TenantId.SYS_TENANT_ID, resetToken);
if (userCredentials != null) { if (userCredentials != null) {
if (userCredentials.isResetTokenExpired()) {
throw new ThingsboardException("Password reset token expired", ThingsboardErrorCode.BAD_REQUEST_PARAMS);
}
systemSecurityService.validatePassword(password, userCredentials); systemSecurityService.validatePassword(password, userCredentials);
if (passwordEncoder.matches(password, userCredentials.getPassword())) { if (passwordEncoder.matches(password, userCredentials.getPassword())) {
throw new ThingsboardException("New password should be different from existing!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); throw new ThingsboardException("New password should be different from existing!", ThingsboardErrorCode.BAD_REQUEST_PARAMS);
@ -277,6 +246,7 @@ public class AuthController extends BaseController {
String encodedPassword = passwordEncoder.encode(password); String encodedPassword = passwordEncoder.encode(password);
userCredentials.setPassword(encodedPassword); userCredentials.setPassword(encodedPassword);
userCredentials.setResetToken(null); userCredentials.setResetToken(null);
userCredentials.setResetTokenExpTime(null);
userCredentials = userService.replaceUserCredentials(TenantId.SYS_TENANT_ID, userCredentials); userCredentials = userService.replaceUserCredentials(TenantId.SYS_TENANT_ID, userCredentials);
User user = userService.findUserById(TenantId.SYS_TENANT_ID, userCredentials.getUserId()); User user = userService.findUserById(TenantId.SYS_TENANT_ID, userCredentials.getUserId());
UserPrincipal principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail()); UserPrincipal principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail());

25
application/src/main/java/org/thingsboard/server/controller/BaseController.java

@ -27,7 +27,9 @@ import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataAccessException; import org.springframework.dao.DataAccessException;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
@ -132,8 +134,8 @@ import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.exception.IncorrectParameterException;
import org.thingsboard.server.dao.mobile.MobileAppService; import org.thingsboard.server.dao.mobile.MobileAppService;
import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.model.ModelConstants;
import org.thingsboard.server.dao.oauth2.OAuth2ConfigTemplateService;
import org.thingsboard.server.dao.oauth2.OAuth2ClientService; import org.thingsboard.server.dao.oauth2.OAuth2ClientService;
import org.thingsboard.server.dao.oauth2.OAuth2ConfigTemplateService;
import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.ota.OtaPackageService;
import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.relation.RelationService;
@ -170,8 +172,8 @@ import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService;
import org.thingsboard.server.service.telemetry.AlarmSubscriptionService; import org.thingsboard.server.service.telemetry.AlarmSubscriptionService;
import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService;
import java.net.URI;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@ -191,7 +193,7 @@ import static org.thingsboard.server.dao.service.Validator.validateId;
@TbCoreComponent @TbCoreComponent
public abstract class BaseController { public abstract class BaseController {
private final Logger log = org.slf4j.LoggerFactory.getLogger(getClass()); protected final Logger log = org.slf4j.LoggerFactory.getLogger(getClass());
/*Swagger UI description*/ /*Swagger UI description*/
@ -912,6 +914,23 @@ public abstract class BaseController {
} }
} }
protected <T> ResponseEntity<T> response(HttpStatus status) {
return ResponseEntity.status(status).build();
}
protected <T> ResponseEntity<T> redirectTo(String location) {
URI uri;
try {
uri = URI.create(location);
} catch (IllegalArgumentException e) {
log.error("Failed to create URI from '{}'", location, e);
throw e;
}
return ResponseEntity.status(HttpStatus.SEE_OTHER)
.location(uri)
.build();
}
protected List<OAuth2ClientId> getOAuth2ClientIds(UUID[] ids) throws ThingsboardException { protected List<OAuth2ClientId> getOAuth2ClientIds(UUID[] ids) throws ThingsboardException {
if (ids == null) { if (ids == null) {
return Collections.emptyList(); return Collections.emptyList();

2
application/src/main/java/org/thingsboard/server/controller/ImageController.java

@ -312,7 +312,7 @@ public class ImageController extends BaseController {
if (StringUtils.isNotEmpty(etag)) { if (StringUtils.isNotEmpty(etag)) {
etag = StringUtils.remove(etag, '\"'); // etag is wrapped in double quotes due to HTTP specification etag = StringUtils.remove(etag, '\"'); // etag is wrapped in double quotes due to HTTP specification
if (etag.equals(tbImageService.getETag(cacheKey))) { if (etag.equals(tbImageService.getETag(cacheKey))) {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build(); return response(HttpStatus.NOT_MODIFIED);
} }
} }

3
application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java

@ -183,8 +183,7 @@ public class MobileApplicationController extends BaseController {
.header("Location", appStoreLink) .header("Location", appStoreLink)
.build(); .build();
} else { } else {
return ResponseEntity.status(HttpStatus.NOT_FOUND) return response(HttpStatus.NOT_FOUND);
.build();
} }
} }

69
application/src/main/java/org/thingsboard/server/controller/UserController.java

@ -21,7 +21,6 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -44,6 +43,7 @@ import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.UserActivationLink;
import org.thingsboard.server.common.data.UserEmailInfo; import org.thingsboard.server.common.data.UserEmailInfo;
import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
@ -119,7 +119,6 @@ public class UserController extends BaseController {
public static final String USER_ID = "userId"; public static final String USER_ID = "userId";
public static final String PATHS = "paths"; public static final String PATHS = "paths";
public static final String YOU_DON_T_HAVE_PERMISSION_TO_PERFORM_THIS_OPERATION = "You don't have permission to perform this operation!"; public static final String YOU_DON_T_HAVE_PERMISSION_TO_PERFORM_THIS_OPERATION = "You don't have permission to perform this operation!";
public static final String ACTIVATE_URL_PATTERN = "%s/api/noauth/activate?activateToken=%s";
public static final String MOBILE_TOKEN_HEADER = "X-Mobile-Token"; public static final String MOBILE_TOKEN_HEADER = "X-Mobile-Token";
@Value("${security.user_token_access_enabled}") @Value("${security.user_token_access_enabled}")
@ -130,12 +129,8 @@ public class UserController extends BaseController {
private final SystemSecurityService systemSecurityService; private final SystemSecurityService systemSecurityService;
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
private final TbUserService tbUserService; private final TbUserService tbUserService;
private final EntityQueryService entityQueryService;
@Autowired private final EntityService entityService;
private EntityQueryService entityQueryService;
@Autowired
private EntityService entityService;
@ApiOperation(value = "Get User (getUserById)", @ApiOperation(value = "Get User (getUserById)",
notes = "Fetch the User object based on the provided User Id. " + notes = "Fetch the User object based on the provided User Id. " +
@ -212,7 +207,7 @@ public class UserController extends BaseController {
public User saveUser( public User saveUser(
@Parameter(description = "A JSON value representing the User.", required = true) @Parameter(description = "A JSON value representing the User.", required = true)
@RequestBody User user, @RequestBody User user,
@Parameter(description = "Send activation email (or use activation link)" , schema = @Schema(defaultValue = "true")) @Parameter(description = "Send activation email (or use activation link)", schema = @Schema(defaultValue = "true"))
@RequestParam(required = false, defaultValue = "true") boolean sendActivationMail, HttpServletRequest request) throws ThingsboardException { @RequestParam(required = false, defaultValue = "true") boolean sendActivationMail, HttpServletRequest request) throws ThingsboardException {
if (!Authority.SYS_ADMIN.equals(getCurrentUser().getAuthority())) { if (!Authority.SYS_ADMIN.equals(getCurrentUser().getAuthority())) {
user.setTenantId(getCurrentUser().getTenantId()); user.setTenantId(getCurrentUser().getTenantId());
@ -230,45 +225,39 @@ public class UserController extends BaseController {
@Parameter(description = "Email of the user", required = true) @Parameter(description = "Email of the user", required = true)
@RequestParam(value = "email") String email, @RequestParam(value = "email") String email,
HttpServletRequest request) throws ThingsboardException { HttpServletRequest request) throws ThingsboardException {
User user = checkNotNull(userService.findUserByEmail(getCurrentUser().getTenantId(), email)); SecurityUser securityUser = getCurrentUser();
User user = checkNotNull(userService.findUserByEmail(securityUser.getTenantId(), email));
accessControlService.checkPermission(getCurrentUser(), Resource.USER, Operation.READ, accessControlService.checkPermission(securityUser, Resource.USER, Operation.READ, user.getId(), user);
user.getId(), user);
UserCredentials userCredentials = userService.findUserCredentialsByUserId(getCurrentUser().getTenantId(), user.getId()); UserActivationLink activationLink = tbUserService.getActivationLink(securityUser.getTenantId(), securityUser.getCustomerId(), user.getId(), request);
if (!userCredentials.isEnabled() && userCredentials.getActivateToken() != null) { mailService.sendActivationEmail(activationLink.value(), activationLink.ttlMs(), email);
String baseUrl = systemSecurityService.getBaseUrl(getTenantId(), getCurrentUser().getCustomerId(), request);
String activateUrl = String.format(ACTIVATE_URL_PATTERN, baseUrl,
userCredentials.getActivateToken());
mailService.sendActivationEmail(activateUrl, email);
} else {
throw new ThingsboardException("User is already activated!", ThingsboardErrorCode.BAD_REQUEST_PARAMS);
}
} }
@ApiOperation(value = "Get the activation link (getActivationLink)", @ApiOperation(value = "Get activation link (getActivationLink)",
notes = "Get the activation link for the user. " + notes = "Get the activation link for the user. " +
"The base url for activation link is configurable in the general settings of system administrator. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) "The base url for activation link is configurable in the general settings of system administrator. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@RequestMapping(value = "/user/{userId}/activationLink", method = RequestMethod.GET, produces = "text/plain") @GetMapping(value = "/user/{userId}/activationLink", produces = "text/plain")
@ResponseBody @ResponseBody
public String getActivationLink( public String getActivationLink(@Parameter(description = USER_ID_PARAM_DESCRIPTION)
@Parameter(description = USER_ID_PARAM_DESCRIPTION) @PathVariable(USER_ID) String strUserId,
@PathVariable(USER_ID) String strUserId, HttpServletRequest request) throws ThingsboardException {
HttpServletRequest request) throws ThingsboardException { return getActivationLinkInfo(strUserId, request).value();
}
@ApiOperation(value = "Get activation link info (getActivationLinkInfo)",
notes = "Get the activation link info for the user. " +
"The base url for activation link is configurable in the general settings of system administrator. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@GetMapping(value = "/user/{userId}/activationLinkInfo")
public UserActivationLink getActivationLinkInfo(@Parameter(description = USER_ID_PARAM_DESCRIPTION)
@PathVariable(USER_ID) String strUserId,
HttpServletRequest request) throws ThingsboardException {
checkParameter(USER_ID, strUserId); checkParameter(USER_ID, strUserId);
UserId userId = new UserId(toUUID(strUserId)); UserId userId = new UserId(toUUID(strUserId));
User user = checkUserId(userId, Operation.READ); checkUserId(userId, Operation.READ);
SecurityUser authUser = getCurrentUser(); SecurityUser securityUser = getCurrentUser();
UserCredentials userCredentials = userService.findUserCredentialsByUserId(authUser.getTenantId(), user.getId()); return tbUserService.getActivationLink(securityUser.getTenantId(), securityUser.getCustomerId(), userId, request);
if (!userCredentials.isEnabled() && userCredentials.getActivateToken() != null) {
String baseUrl = systemSecurityService.getBaseUrl(getTenantId(), getCurrentUser().getCustomerId(), request);
String activateUrl = String.format(ACTIVATE_URL_PATTERN, baseUrl,
userCredentials.getActivateToken());
return activateUrl;
} else {
throw new ThingsboardException("User is already activated!", ThingsboardErrorCode.BAD_REQUEST_PARAMS);
}
} }
@ApiOperation(value = "Delete User (deleteUser)", @ApiOperation(value = "Delete User (deleteUser)",
@ -411,7 +400,7 @@ public class UserController extends BaseController {
public void setUserCredentialsEnabled( public void setUserCredentialsEnabled(
@Parameter(description = USER_ID_PARAM_DESCRIPTION) @Parameter(description = USER_ID_PARAM_DESCRIPTION)
@PathVariable(USER_ID) String strUserId, @PathVariable(USER_ID) String strUserId,
@Parameter(description = "Enable (\"true\") or disable (\"false\") the credentials." , schema = @Schema(defaultValue = "true")) @Parameter(description = "Enable (\"true\") or disable (\"false\") the credentials.", schema = @Schema(defaultValue = "true"))
@RequestParam(required = false, defaultValue = "true") boolean userCredentialsEnabled) throws ThingsboardException { @RequestParam(required = false, defaultValue = "true") boolean userCredentialsEnabled) throws ThingsboardException {
checkParameter(USER_ID, strUserId); checkParameter(USER_ID, strUserId);
UserId userId = new UserId(toUUID(strUserId)); UserId userId = new UserId(toUUID(strUserId));

13
application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java

@ -34,8 +34,10 @@ import org.thingsboard.server.common.data.alarm.AlarmComment;
import org.thingsboard.server.common.data.alarm.EntityAlarm; import org.thingsboard.server.common.data.alarm.EntityAlarm;
import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.domain.Domain; import org.thingsboard.server.common.data.domain.Domain;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.edge.EdgeEventActionType;
import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.data.edge.EdgeEventType;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.relation.RelationTypeGroup;
@ -134,6 +136,17 @@ public class EdgeEventSourcingListener {
return; return;
} }
try { try {
if (event.getEntityId().getEntityType().equals(EntityType.RULE_CHAIN) && event.getEdgeId() != null && event.getActionType().equals(ActionType.ASSIGNED_TO_EDGE)) {
try {
Edge edge = JacksonUtil.fromString(event.getBody(), Edge.class);
if (edge != null && new RuleChainId(event.getEntityId().getId()).equals(edge.getRootRuleChainId())) {
log.trace("[{}] skipping ASSIGNED_TO_EDGE event of RULE_CHAIN entity in case Edge Root Rule Chain: {}", event.getTenantId(), event);
return;
}
} catch (Exception ignored) {
return;
}
}
log.trace("[{}] ActionEntityEvent called: {}", event.getTenantId(), event); log.trace("[{}] ActionEntityEvent called: {}", event.getTenantId(), event);
tbClusterService.sendNotificationMsgToEdge(event.getTenantId(), event.getEdgeId(), event.getEntityId(), tbClusterService.sendNotificationMsgToEdge(event.getTenantId(), event.getEdgeId(), event.getEntityId(),
event.getBody(), null, EdgeUtils.getEdgeEventActionTypeByActionType(event.getActionType()), event.getBody(), null, EdgeUtils.getEdgeEventActionTypeByActionType(event.getActionType()),

2
application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java

@ -55,6 +55,7 @@ public class RelatedEdgesSourcingListener {
@TransactionalEventListener(fallbackExecution = true) @TransactionalEventListener(fallbackExecution = true)
public void handleEvent(ActionEntityEvent<?> event) { public void handleEvent(ActionEntityEvent<?> event) {
executorService.submit(() -> { executorService.submit(() -> {
log.trace("[{}] ActionEntityEvent called: {}", event.getTenantId(), event);
try { try {
switch (event.getActionType()) { switch (event.getActionType()) {
case ASSIGNED_TO_EDGE, UNASSIGNED_FROM_EDGE -> case ASSIGNED_TO_EDGE, UNASSIGNED_FROM_EDGE ->
@ -69,6 +70,7 @@ public class RelatedEdgesSourcingListener {
@TransactionalEventListener(fallbackExecution = true) @TransactionalEventListener(fallbackExecution = true)
public void handleEvent(DeleteEntityEvent<?> event) { public void handleEvent(DeleteEntityEvent<?> event) {
executorService.submit(() -> { executorService.submit(() -> {
log.trace("[{}] DeleteEntityEvent called: {}", event.getTenantId(), event);
try { try {
relatedEdgesService.publishRelatedEdgeIdsEvictEvent(event.getTenantId(), event.getEntityId()); relatedEdgesService.publishRelatedEdgeIdsEvictEvent(event.getTenantId(), event.getEntityId());
} catch (Exception e) { } catch (Exception e) {

32
application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java

@ -22,7 +22,9 @@ import org.springframework.stereotype.Service;
import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.UserActivationLink;
import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
@ -33,7 +35,7 @@ import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.entitiy.AbstractTbEntityService; import org.thingsboard.server.service.entitiy.AbstractTbEntityService;
import org.thingsboard.server.service.security.system.SystemSecurityService; import org.thingsboard.server.service.security.system.SystemSecurityService;
import static org.thingsboard.server.controller.UserController.ACTIVATE_URL_PATTERN; import java.util.concurrent.TimeUnit;
@Service @Service
@TbCoreComponent @TbCoreComponent
@ -53,13 +55,9 @@ public class DefaultUserService extends AbstractTbEntityService implements TbUse
boolean sendEmail = tbUser.getId() == null && sendActivationMail; boolean sendEmail = tbUser.getId() == null && sendActivationMail;
User savedUser = checkNotNull(userService.saveUser(tenantId, tbUser)); User savedUser = checkNotNull(userService.saveUser(tenantId, tbUser));
if (sendEmail) { if (sendEmail) {
UserCredentials userCredentials = userService.findUserCredentialsByUserId(tenantId, savedUser.getId()); UserActivationLink activationLink = getActivationLink(tenantId, customerId, savedUser.getId(), request);
String baseUrl = systemSecurityService.getBaseUrl(tenantId, customerId, request);
String activateUrl = String.format(ACTIVATE_URL_PATTERN, baseUrl,
userCredentials.getActivateToken());
String email = savedUser.getEmail();
try { try {
mailService.sendActivationEmail(activateUrl, email); mailService.sendActivationEmail(activationLink.value(), activationLink.ttlMs(), savedUser.getEmail());
} catch (ThingsboardException e) { } catch (ThingsboardException e) {
userService.deleteUser(tenantId, savedUser); userService.deleteUser(tenantId, savedUser);
throw e; throw e;
@ -87,4 +85,24 @@ public class DefaultUserService extends AbstractTbEntityService implements TbUse
throw e; throw e;
} }
} }
@Override
public UserActivationLink getActivationLink(TenantId tenantId, CustomerId customerId, UserId userId, HttpServletRequest request) throws ThingsboardException {
UserCredentials userCredentials = userService.findUserCredentialsByUserId(tenantId, userId);
if (!userCredentials.isEnabled() && userCredentials.getActivateToken() != null) {
long ttl = userCredentials.getActivationTokenTtl();
if (ttl < TimeUnit.MINUTES.toMillis(15)) { // renew link if less than 15 minutes before expiration
userCredentials = userService.generateUserActivationToken(userCredentials);
userCredentials = userService.saveUserCredentials(tenantId, userCredentials);
ttl = userCredentials.getActivationTokenTtl();
log.debug("[{}][{}] Regenerated expired user activation token", tenantId, userId);
}
String baseUrl = systemSecurityService.getBaseUrl(tenantId, customerId, request);
String link = baseUrl + "/api/noauth/activate?activateToken=" + userCredentials.getActivateToken();
return new UserActivationLink(link, ttl);
} else {
throw new ThingsboardException("User is already activated!", ThingsboardErrorCode.BAD_REQUEST_PARAMS);
}
}
} }

5
application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java

@ -16,14 +16,19 @@
package org.thingsboard.server.service.entitiy.user; package org.thingsboard.server.service.entitiy.user;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.thingsboard.server.common.data.UserActivationLink;
import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
public interface TbUserService { public interface TbUserService {
User save(TenantId tenantId, CustomerId customerId, User tbUser, boolean sendActivationMail, HttpServletRequest request, User user) throws ThingsboardException; User save(TenantId tenantId, CustomerId customerId, User tbUser, boolean sendActivationMail, HttpServletRequest request, User user) throws ThingsboardException;
void delete(TenantId tenantId, CustomerId customerId, User user, User responsibleUser) throws ThingsboardException; void delete(TenantId tenantId, CustomerId customerId, User user, User responsibleUser) throws ThingsboardException;
UserActivationLink getActivationLink(TenantId tenantId, CustomerId customerId, UserId userId, HttpServletRequest request) throws ThingsboardException;
} }

1
application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java

@ -92,4 +92,5 @@ public class DefaultCacheCleanupService implements CacheCleanupService {
} }
cacheManager.getCacheNames().forEach(this::clearCacheByName); cacheManager.getCacheNames().forEach(this::clearCacheByName);
} }
} }

11
application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java

@ -160,12 +160,12 @@ public class DefaultMailService implements MailService {
} }
@Override @Override
public void sendActivationEmail(String activationLink, String email) throws ThingsboardException { public void sendActivationEmail(String activationLink, long ttlMs, String email) throws ThingsboardException {
String subject = messages.getMessage("activation.subject", null, Locale.US); String subject = messages.getMessage("activation.subject", null, Locale.US);
Map<String, Object> model = new HashMap<>(); Map<String, Object> model = new HashMap<>();
model.put("activationLink", activationLink); model.put("activationLink", activationLink);
model.put("activationLinkTtlInHours", (int) Math.ceil(ttlMs / 3600000.0));
model.put(TARGET_EMAIL, email); model.put(TARGET_EMAIL, email);
String message = mergeTemplateIntoString("activation.ftl", model); String message = mergeTemplateIntoString("activation.ftl", model);
@ -188,12 +188,13 @@ public class DefaultMailService implements MailService {
} }
@Override @Override
public void sendResetPasswordEmail(String passwordResetLink, String email) throws ThingsboardException { public void sendResetPasswordEmail(String passwordResetLink, long ttlMs, String email) throws ThingsboardException {
String subject = messages.getMessage("reset.password.subject", null, Locale.US); String subject = messages.getMessage("reset.password.subject", null, Locale.US);
Map<String, Object> model = new HashMap<>(); Map<String, Object> model = new HashMap<>();
model.put("passwordResetLink", passwordResetLink); model.put("passwordResetLink", passwordResetLink);
model.put("passwordResetLinkTtlInHours", (int) Math.ceil(ttlMs / 3600000.0));
model.put(TARGET_EMAIL, email); model.put(TARGET_EMAIL, email);
String message = mergeTemplateIntoString("reset.password.ftl", model); String message = mergeTemplateIntoString("reset.password.ftl", model);
@ -202,10 +203,10 @@ public class DefaultMailService implements MailService {
} }
@Override @Override
public void sendResetPasswordEmailAsync(String passwordResetLink, String email) { public void sendResetPasswordEmailAsync(String passwordResetLink, long ttlMs, String email) {
passwordResetExecutorService.execute(() -> { passwordResetExecutorService.execute(() -> {
try { try {
this.sendResetPasswordEmail(passwordResetLink, email); this.sendResetPasswordEmail(passwordResetLink, ttlMs, email);
} catch (Exception e) { } catch (Exception e) {
log.error("Error occurred: {} ", e.getMessage()); log.error("Error occurred: {} ", e.getMessage());
} }

2
application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java

@ -32,7 +32,7 @@ public class DefaultTbMailConfigTemplateService implements TbMailConfigTemplateS
@PostConstruct @PostConstruct
private void postConstruct() throws IOException { private void postConstruct() throws IOException {
mailConfigTemplates = JacksonUtil.toJsonNode(new ClassPathResource("/templates/mail_config_templates.json").getFile()); mailConfigTemplates = JacksonUtil.toJsonNode(new ClassPathResource("/templates/mail_config_templates.json").getInputStream());
} }
@Override @Override

10
application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretServiceImpl.java

@ -25,11 +25,12 @@ import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.security.model.JwtPair; import org.thingsboard.server.common.data.security.model.JwtPair;
import org.thingsboard.server.dao.entity.AbstractCachedService; import org.thingsboard.server.dao.entity.AbstractCachedService;
import org.thingsboard.server.dao.settings.SecuritySettingsService;
import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import org.thingsboard.server.service.security.model.token.JwtTokenFactory;
import org.thingsboard.server.service.security.system.SystemSecurityService;
import static org.thingsboard.server.service.security.system.DefaultSystemSecurityService.DEFAULT_MOBILE_SECRET_KEY_LENGTH; import static org.thingsboard.server.dao.settings.DefaultSecuritySettingsService.DEFAULT_MOBILE_SECRET_KEY_LENGTH;
@Service @Service
@Slf4j @Slf4j
@ -37,12 +38,12 @@ import static org.thingsboard.server.service.security.system.DefaultSystemSecuri
public class MobileAppSecretServiceImpl extends AbstractCachedService<String, JwtPair, MobileSecretEvictEvent> implements MobileAppSecretService { public class MobileAppSecretServiceImpl extends AbstractCachedService<String, JwtPair, MobileSecretEvictEvent> implements MobileAppSecretService {
private final JwtTokenFactory tokenFactory; private final JwtTokenFactory tokenFactory;
private final SystemSecurityService systemSecurityService; private final SecuritySettingsService securitySettingsService;
@Override @Override
public String generateMobileAppSecret(SecurityUser securityUser) { public String generateMobileAppSecret(SecurityUser securityUser) {
log.trace("Executing generateSecret for user [{}]", securityUser.getId()); log.trace("Executing generateSecret for user [{}]", securityUser.getId());
Integer mobileSecretKeyLength = systemSecurityService.getSecuritySettings().getMobileSecretKeyLength(); Integer mobileSecretKeyLength = securitySettingsService.getSecuritySettings().getMobileSecretKeyLength();
String secret = StringUtils.generateSafeToken(mobileSecretKeyLength == null ? DEFAULT_MOBILE_SECRET_KEY_LENGTH : mobileSecretKeyLength); String secret = StringUtils.generateSafeToken(mobileSecretKeyLength == null ? DEFAULT_MOBILE_SECRET_KEY_LENGTH : mobileSecretKeyLength);
cache.put(secret, tokenFactory.createTokenPair(securityUser)); cache.put(secret, tokenFactory.createTokenPair(securityUser));
return secret; return secret;
@ -63,4 +64,5 @@ public class MobileAppSecretServiceImpl extends AbstractCachedService<String, Jw
public void handleEvictEvent(MobileSecretEvictEvent event) { public void handleEvictEvent(MobileSecretEvictEvent event) {
cache.evict(event.getSecret()); cache.evict(event.getSecret());
} }
} }

8
application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java

@ -40,6 +40,7 @@ import org.thingsboard.server.common.data.security.model.SecuritySettings;
import org.thingsboard.server.common.data.security.model.UserPasswordPolicy; import org.thingsboard.server.common.data.security.model.UserPasswordPolicy;
import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.customer.CustomerService;
import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.settings.SecuritySettingsService;
import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.auth.MfaAuthenticationToken; import org.thingsboard.server.service.security.auth.MfaAuthenticationToken;
@ -58,6 +59,7 @@ import java.util.UUID;
public class RestAuthenticationProvider implements AuthenticationProvider { public class RestAuthenticationProvider implements AuthenticationProvider {
private final SystemSecurityService systemSecurityService; private final SystemSecurityService systemSecurityService;
private final SecuritySettingsService securitySettingsService;
private final UserService userService; private final UserService userService;
private final CustomerService customerService; private final CustomerService customerService;
private final TwoFactorAuthService twoFactorAuthService; private final TwoFactorAuthService twoFactorAuthService;
@ -66,10 +68,12 @@ public class RestAuthenticationProvider implements AuthenticationProvider {
public RestAuthenticationProvider(final UserService userService, public RestAuthenticationProvider(final UserService userService,
final CustomerService customerService, final CustomerService customerService,
final SystemSecurityService systemSecurityService, final SystemSecurityService systemSecurityService,
SecuritySettingsService securitySettingsService,
TwoFactorAuthService twoFactorAuthService) { TwoFactorAuthService twoFactorAuthService) {
this.userService = userService; this.userService = userService;
this.customerService = customerService; this.customerService = customerService;
this.systemSecurityService = systemSecurityService; this.systemSecurityService = systemSecurityService;
this.securitySettingsService = securitySettingsService;
this.twoFactorAuthService = twoFactorAuthService; this.twoFactorAuthService = twoFactorAuthService;
} }
@ -82,13 +86,13 @@ public class RestAuthenticationProvider implements AuthenticationProvider {
throw new BadCredentialsException("Authentication Failed. Bad user principal."); throw new BadCredentialsException("Authentication Failed. Bad user principal.");
} }
UserPrincipal userPrincipal = (UserPrincipal) principal; UserPrincipal userPrincipal = (UserPrincipal) principal;
SecurityUser securityUser; SecurityUser securityUser;
if (userPrincipal.getType() == UserPrincipal.Type.USER_NAME) { if (userPrincipal.getType() == UserPrincipal.Type.USER_NAME) {
String username = userPrincipal.getValue(); String username = userPrincipal.getValue();
String password = (String) authentication.getCredentials(); String password = (String) authentication.getCredentials();
SecuritySettings securitySettings = systemSecurityService.getSecuritySettings(); SecuritySettings securitySettings = securitySettingsService.getSecuritySettings();
UserPasswordPolicy passwordPolicy = securitySettings.getPasswordPolicy(); UserPasswordPolicy passwordPolicy = securitySettings.getPasswordPolicy();
if (Boolean.TRUE.equals(passwordPolicy.getForceUserToResetPasswordIfNotValid())) { if (Boolean.TRUE.equals(passwordPolicy.getForceUserToResetPasswordIfNotValid())) {
try { try {

82
application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java

@ -18,8 +18,8 @@ package org.thingsboard.server.service.security.system;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.passay.CharacterRule; import org.passay.CharacterRule;
import org.passay.EnglishCharacterData; import org.passay.EnglishCharacterData;
@ -29,9 +29,6 @@ import org.passay.PasswordValidator;
import org.passay.Rule; import org.passay.Rule;
import org.passay.RuleResult; import org.passay.RuleResult;
import org.passay.WhitespaceRule; import org.passay.WhitespaceRule;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException; import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException; import org.springframework.security.authentication.LockedException;
@ -55,6 +52,7 @@ import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettin
import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.audit.AuditLogService;
import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.dao.settings.AdminSettingsService;
import org.thingsboard.server.dao.settings.SecuritySettingsService;
import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.dao.user.UserServiceImpl; import org.thingsboard.server.dao.user.UserServiceImpl;
import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails;
@ -68,76 +66,23 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import static org.thingsboard.server.common.data.CacheConstants.SECURITY_SETTINGS_CACHE;
@Service @Service
@Slf4j @Slf4j
@RequiredArgsConstructor
public class DefaultSystemSecurityService implements SystemSecurityService { public class DefaultSystemSecurityService implements SystemSecurityService {
public static final int DEFAULT_MOBILE_SECRET_KEY_LENGTH = 64; private final AdminSettingsService adminSettingsService;
private final BCryptPasswordEncoder encoder;
@Autowired private final UserService userService;
private AdminSettingsService adminSettingsService; private final MailService mailService;
private final AuditLogService auditLogService;
@Autowired private final SecuritySettingsService securitySettingsService;
private BCryptPasswordEncoder encoder;
@Autowired
private UserService userService;
@Autowired
private MailService mailService;
@Autowired
private AuditLogService auditLogService;
@Resource
private SystemSecurityService self;
@Cacheable(cacheNames = SECURITY_SETTINGS_CACHE, key = "'securitySettings'")
@Override
public SecuritySettings getSecuritySettings() {
SecuritySettings securitySettings = null;
AdminSettings adminSettings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "securitySettings");
if (adminSettings != null) {
try {
securitySettings = JacksonUtil.convertValue(adminSettings.getJsonValue(), SecuritySettings.class);
} catch (Exception e) {
throw new RuntimeException("Failed to load security settings!", e);
}
} else {
securitySettings = new SecuritySettings();
securitySettings.setPasswordPolicy(new UserPasswordPolicy());
securitySettings.getPasswordPolicy().setMinimumLength(6);
securitySettings.getPasswordPolicy().setMaximumLength(72);
securitySettings.setMobileSecretKeyLength(DEFAULT_MOBILE_SECRET_KEY_LENGTH);
}
return securitySettings;
}
@CacheEvict(cacheNames = SECURITY_SETTINGS_CACHE, key = "'securitySettings'")
@Override
public SecuritySettings saveSecuritySettings(SecuritySettings securitySettings) {
AdminSettings adminSettings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "securitySettings");
if (adminSettings == null) {
adminSettings = new AdminSettings();
adminSettings.setTenantId(TenantId.SYS_TENANT_ID);
adminSettings.setKey("securitySettings");
}
adminSettings.setJsonValue(JacksonUtil.valueToTree(securitySettings));
AdminSettings savedAdminSettings = adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, adminSettings);
try {
return JacksonUtil.convertValue(savedAdminSettings.getJsonValue(), SecuritySettings.class);
} catch (Exception e) {
throw new RuntimeException("Failed to load security settings!", e);
}
}
@Override @Override
public void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String username, String password) throws AuthenticationException { public void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String username, String password) throws AuthenticationException {
if (!encoder.matches(password, userCredentials.getPassword())) { if (!encoder.matches(password, userCredentials.getPassword())) {
int failedLoginAttempts = userService.increaseFailedLoginAttempts(tenantId, userCredentials.getUserId()); int failedLoginAttempts = userService.increaseFailedLoginAttempts(tenantId, userCredentials.getUserId());
SecuritySettings securitySettings = self.getSecuritySettings(); SecuritySettings securitySettings = securitySettingsService.getSecuritySettings();
if (securitySettings.getMaxFailedLoginAttempts() != null && securitySettings.getMaxFailedLoginAttempts() > 0) { if (securitySettings.getMaxFailedLoginAttempts() != null && securitySettings.getMaxFailedLoginAttempts() > 0) {
if (failedLoginAttempts > securitySettings.getMaxFailedLoginAttempts() && userCredentials.isEnabled()) { if (failedLoginAttempts > securitySettings.getMaxFailedLoginAttempts() && userCredentials.isEnabled()) {
lockAccount(userCredentials.getUserId(), username, securitySettings.getUserLockoutNotificationEmail(), securitySettings.getMaxFailedLoginAttempts()); lockAccount(userCredentials.getUserId(), username, securitySettings.getUserLockoutNotificationEmail(), securitySettings.getMaxFailedLoginAttempts());
@ -153,7 +98,7 @@ public class DefaultSystemSecurityService implements SystemSecurityService {
userService.resetFailedLoginAttempts(tenantId, userCredentials.getUserId()); userService.resetFailedLoginAttempts(tenantId, userCredentials.getUserId());
SecuritySettings securitySettings = self.getSecuritySettings(); SecuritySettings securitySettings = securitySettingsService.getSecuritySettings();
if (isPositiveInteger(securitySettings.getPasswordPolicy().getPasswordExpirationPeriodDays())) { if (isPositiveInteger(securitySettings.getPasswordPolicy().getPasswordExpirationPeriodDays())) {
if ((userCredentials.getCreatedTime() if ((userCredentials.getCreatedTime()
+ TimeUnit.DAYS.toMillis(securitySettings.getPasswordPolicy().getPasswordExpirationPeriodDays())) + TimeUnit.DAYS.toMillis(securitySettings.getPasswordPolicy().getPasswordExpirationPeriodDays()))
@ -181,7 +126,7 @@ public class DefaultSystemSecurityService implements SystemSecurityService {
if (maxVerificationFailures != null && maxVerificationFailures > 0 if (maxVerificationFailures != null && maxVerificationFailures > 0
&& failedVerificationAttempts >= maxVerificationFailures) { && failedVerificationAttempts >= maxVerificationFailures) {
userService.setUserCredentialsEnabled(TenantId.SYS_TENANT_ID, userId, false); userService.setUserCredentialsEnabled(TenantId.SYS_TENANT_ID, userId, false);
SecuritySettings securitySettings = self.getSecuritySettings(); SecuritySettings securitySettings = securitySettingsService.getSecuritySettings();
lockAccount(userId, securityUser.getEmail(), securitySettings.getUserLockoutNotificationEmail(), maxVerificationFailures); lockAccount(userId, securityUser.getEmail(), securitySettings.getUserLockoutNotificationEmail(), maxVerificationFailures);
throw new LockedException("User account was locked due to exceeded 2FA verification attempts"); throw new LockedException("User account was locked due to exceeded 2FA verification attempts");
} }
@ -200,7 +145,7 @@ public class DefaultSystemSecurityService implements SystemSecurityService {
@Override @Override
public void validatePassword(String password, UserCredentials userCredentials) throws DataValidationException { public void validatePassword(String password, UserCredentials userCredentials) throws DataValidationException {
SecuritySettings securitySettings = self.getSecuritySettings(); SecuritySettings securitySettings = securitySettingsService.getSecuritySettings();
UserPasswordPolicy passwordPolicy = securitySettings.getPasswordPolicy(); UserPasswordPolicy passwordPolicy = securitySettings.getPasswordPolicy();
validatePasswordByPolicy(password, passwordPolicy); validatePasswordByPolicy(password, passwordPolicy);
@ -330,4 +275,5 @@ public class DefaultSystemSecurityService implements SystemSecurityService {
private static boolean isPositiveInteger(Integer val) { private static boolean isPositiveInteger(Integer val) {
return val != null && val.intValue() > 0; return val != null && val.intValue() > 0;
} }
} }

6
application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java

@ -22,7 +22,6 @@ import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.common.data.security.UserCredentials;
import org.thingsboard.server.common.data.security.model.SecuritySettings;
import org.thingsboard.server.common.data.security.model.UserPasswordPolicy; import org.thingsboard.server.common.data.security.model.UserPasswordPolicy;
import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings;
import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.DataValidationException;
@ -30,10 +29,6 @@ import org.thingsboard.server.service.security.model.SecurityUser;
public interface SystemSecurityService { public interface SystemSecurityService {
SecuritySettings getSecuritySettings();
SecuritySettings saveSecuritySettings(SecuritySettings securitySettings);
void validatePasswordByPolicy(String password, UserPasswordPolicy passwordPolicy); void validatePasswordByPolicy(String password, UserPasswordPolicy passwordPolicy);
void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String username, String password) throws AuthenticationException; void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String username, String password) throws AuthenticationException;
@ -47,4 +42,5 @@ public interface SystemSecurityService {
void logLoginAction(User user, Object authenticationDetails, ActionType actionType, Exception e); void logLoginAction(User user, Object authenticationDetails, ActionType actionType, Exception e);
void logLoginAction(User user, Object authenticationDetails, ActionType actionType, String provider, Exception e); void logLoginAction(User user, Object authenticationDetails, ActionType actionType, String provider, Exception e);
} }

2
application/src/main/resources/templates/activation.ftl

@ -88,7 +88,7 @@ background-color: #f6f6f6;
</tr> </tr>
<tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"> <tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<td class="content-block" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top"> <td class="content-block" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
To confirm your email address and choose a password, just click the button below. To confirm your email address and choose a password, just click the button below. The link will expire in ${activationLinkTtlInHours} hours.
</td> </td>
</tr> </tr>
<tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"> <tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">

2
application/src/main/resources/templates/reset.password.ftl

@ -93,7 +93,7 @@ background-color: #f6f6f6;
</tr> </tr>
<tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"> <tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<td class="content-block" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top"> <td class="content-block" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
Click below in order to proceed password reset procedure. Click below in order to proceed password reset procedure. The link will expire in ${passwordResetLinkTtlInHours} hours.
</td> </td>
</tr> </tr>
<tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"> <tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">

5
application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java

@ -145,6 +145,7 @@ import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch;
@ -340,7 +341,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
currentActivateToken = activationLink.split("=")[1]; currentActivateToken = activationLink.split("=")[1];
return null; return null;
} }
}).when(mailService).sendActivationEmail(anyString(), anyString()); }).when(mailService).sendActivationEmail(anyString(), anyLong(), anyString());
Mockito.doAnswer(new Answer<Void>() { Mockito.doAnswer(new Answer<Void>() {
public Void answer(InvocationOnMock invocation) { public Void answer(InvocationOnMock invocation) {
@ -349,7 +350,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
currentResetPasswordToken = passwordResetLink.split("=")[1]; currentResetPasswordToken = passwordResetLink.split("=")[1];
return null; return null;
} }
}).when(mailService).sendResetPasswordEmailAsync(anyString(), anyString()); }).when(mailService).sendResetPasswordEmailAsync(anyString(), anyLong(), anyString());
} }
@After @After

184
application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java

@ -16,20 +16,30 @@
package org.thingsboard.server.controller; package org.thingsboard.server.controller;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import org.assertj.core.data.Offset;
import org.junit.After; import org.junit.After;
import org.junit.Test; import org.junit.Test;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils;
import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.UserActivationLink;
import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.security.UserCredentials;
import org.thingsboard.server.common.data.security.model.SecuritySettings; import org.thingsboard.server.common.data.security.model.SecuritySettings;
import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.dao.user.UserCredentialsDao;
import org.thingsboard.server.service.security.auth.rest.LoginRequest; import org.thingsboard.server.service.security.auth.rest.LoginRequest;
import org.thingsboard.server.service.security.model.ChangePasswordRequest; import org.thingsboard.server.service.security.model.ChangePasswordRequest;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;
import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
@ -39,55 +49,55 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@DaoSqlTest @DaoSqlTest
public class AuthControllerTest extends AbstractControllerTest { public class AuthControllerTest extends AbstractControllerTest {
@SpyBean
private UserCredentialsDao userCredentialsDao;
@After @After
public void tearDown() throws Exception { public void tearDown() throws Exception {
loginSysAdmin(); loginSysAdmin();
SecuritySettings securitySettings = doGet("/api/admin/securitySettings", SecuritySettings.class); updateSecuritySettings(securitySettings -> {
securitySettings.getPasswordPolicy().setMaximumLength(72);
securitySettings.getPasswordPolicy().setMaximumLength(72); securitySettings.getPasswordPolicy().setForceUserToResetPasswordIfNotValid(false);
securitySettings.getPasswordPolicy().setForceUserToResetPasswordIfNotValid(false); });
doPost("/api/admin/securitySettings", securitySettings).andExpect(status().isOk());
} }
@Test @Test
public void testGetUser() throws Exception { public void testGetUser() throws Exception {
doGet("/api/auth/user") doGet("/api/auth/user")
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
loginSysAdmin(); loginSysAdmin();
doGet("/api/auth/user") doGet("/api/auth/user")
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.authority",is(Authority.SYS_ADMIN.name()))) .andExpect(jsonPath("$.authority", is(Authority.SYS_ADMIN.name())))
.andExpect(jsonPath("$.email",is(SYS_ADMIN_EMAIL))); .andExpect(jsonPath("$.email", is(SYS_ADMIN_EMAIL)));
loginTenantAdmin(); loginTenantAdmin();
doGet("/api/auth/user") doGet("/api/auth/user")
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.authority",is(Authority.TENANT_ADMIN.name()))) .andExpect(jsonPath("$.authority", is(Authority.TENANT_ADMIN.name())))
.andExpect(jsonPath("$.email",is(TENANT_ADMIN_EMAIL))); .andExpect(jsonPath("$.email", is(TENANT_ADMIN_EMAIL)));
loginCustomerUser(); loginCustomerUser();
doGet("/api/auth/user") doGet("/api/auth/user")
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.authority",is(Authority.CUSTOMER_USER.name()))) .andExpect(jsonPath("$.authority", is(Authority.CUSTOMER_USER.name())))
.andExpect(jsonPath("$.email",is(CUSTOMER_USER_EMAIL))); .andExpect(jsonPath("$.email", is(CUSTOMER_USER_EMAIL)));
} }
@Test @Test
public void testLoginLogout() throws Exception { public void testLoginLogout() throws Exception {
loginSysAdmin(); loginSysAdmin();
doGet("/api/auth/user") doGet("/api/auth/user")
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.authority",is(Authority.SYS_ADMIN.name()))) .andExpect(jsonPath("$.authority", is(Authority.SYS_ADMIN.name())))
.andExpect(jsonPath("$.email",is(SYS_ADMIN_EMAIL))); .andExpect(jsonPath("$.email", is(SYS_ADMIN_EMAIL)));
TimeUnit.SECONDS.sleep(1); //We need to make sure that event for invalidating token was successfully processed TimeUnit.SECONDS.sleep(1); //We need to make sure that event for invalidating token was successfully processed
logout(); logout();
doGet("/api/auth/user") doGet("/api/auth/user")
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
resetTokens(); resetTokens();
} }
@ -97,14 +107,14 @@ public class AuthControllerTest extends AbstractControllerTest {
loginSysAdmin(); loginSysAdmin();
doGet("/api/auth/user") doGet("/api/auth/user")
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.authority",is(Authority.SYS_ADMIN.name()))) .andExpect(jsonPath("$.authority", is(Authority.SYS_ADMIN.name())))
.andExpect(jsonPath("$.email",is(SYS_ADMIN_EMAIL))); .andExpect(jsonPath("$.email", is(SYS_ADMIN_EMAIL)));
refreshToken(); refreshToken();
doGet("/api/auth/user") doGet("/api/auth/user")
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.authority",is(Authority.SYS_ADMIN.name()))) .andExpect(jsonPath("$.authority", is(Authority.SYS_ADMIN.name())))
.andExpect(jsonPath("$.email",is(SYS_ADMIN_EMAIL))); .andExpect(jsonPath("$.email", is(SYS_ADMIN_EMAIL)));
} }
@Test @Test
@ -131,10 +141,10 @@ public class AuthControllerTest extends AbstractControllerTest {
loginUser(TENANT_ADMIN_EMAIL, newPassword); loginUser(TENANT_ADMIN_EMAIL, newPassword);
loginSysAdmin(); loginSysAdmin();
SecuritySettings securitySettings = doGet("/api/admin/securitySettings", SecuritySettings.class); updateSecuritySettings(securitySettings -> {
securitySettings.getPasswordPolicy().setMaximumLength(15); securitySettings.getPasswordPolicy().setMaximumLength(15);
securitySettings.getPasswordPolicy().setForceUserToResetPasswordIfNotValid(true); securitySettings.getPasswordPolicy().setForceUserToResetPasswordIfNotValid(true);
doPost("/api/admin/securitySettings", securitySettings).andExpect(status().isOk()); });
//try to login with user password that is not valid after security settings was updated //try to login with user password that is not valid after security settings was updated
doPost("/api/auth/login", new LoginRequest(TENANT_ADMIN_EMAIL, newPassword)) doPost("/api/auth/login", new LoginRequest(TENANT_ADMIN_EMAIL, newPassword))
@ -142,6 +152,7 @@ public class AuthControllerTest extends AbstractControllerTest {
.andExpect(jsonPath("$.message", is("The entered password violates our policies. If this is your real password, please reset it."))); .andExpect(jsonPath("$.message", is("The entered password violates our policies. If this is your real password, please reset it.")));
} }
@Test @Test
public void testShouldNotResetPasswordToTooLongValue() throws Exception { public void testShouldNotResetPasswordToTooLongValue() throws Exception {
loginTenantAdmin(); loginTenantAdmin();
@ -163,9 +174,95 @@ public class AuthControllerTest extends AbstractControllerTest {
Mockito.doNothing().when(mailService).sendPasswordWasResetEmail(anyString(), anyString()); Mockito.doNothing().when(mailService).sendPasswordWasResetEmail(anyString(), anyString());
doPost("/api/noauth/resetPassword", resetPasswordRequest) doPost("/api/noauth/resetPassword", resetPasswordRequest)
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message", .andExpect(jsonPath("$.message",
is("Password must be no more than 72 characters in length."))); is("Password must be no more than 72 characters in length.")));
}
@Test
public void testPasswordResetLinkTtl() throws Exception {
loginSysAdmin();
int ttl = 24;
updateSecuritySettings(securitySettings -> {
securitySettings.setPasswordResetTokenTtl(ttl);
});
doPost("/api/noauth/resetPasswordByEmail", JacksonUtil.newObjectNode()
.put("email", TENANT_ADMIN_EMAIL)).andExpect(status().isOk());
UserCredentials userCredentials = userCredentialsDao.findByUserId(tenantId, tenantAdminUserId.getId());
assertThat(userCredentials.getResetTokenExpTime()).isCloseTo(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(ttl), Offset.offset(120000L));
userCredentials.setResetTokenExpTime(System.currentTimeMillis() - 1);
userCredentialsDao.save(tenantId, userCredentials);
doGet("/api/noauth/resetPassword?resetToken={resetToken}", this.currentResetPasswordToken)
.andExpect(status().isSeeOther())
.andExpect(header().string(HttpHeaders.LOCATION, "/passwordResetLinkExpired"));
JsonNode resetPasswordRequest = JacksonUtil.newObjectNode()
.put("resetToken", this.currentResetPasswordToken)
.put("password", "wefwefe");
doPost("/api/noauth/resetPassword", resetPasswordRequest).andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message", is("Password reset token expired")));
}
@Test
public void testActivationLinkTtl() throws Exception {
loginSysAdmin();
int ttl = 24;
updateSecuritySettings(securitySettings -> {
securitySettings.setUserActivationTokenTtl(ttl);
});
loginTenantAdmin();
User user = new User();
user.setAuthority(Authority.TENANT_ADMIN);
user.setEmail("tenant-admin-2@thingsboard.org");
user = doPost("/api/user", user, User.class);
UserCredentials userCredentials = userCredentialsDao.findByUserId(tenantId, user.getUuidId());
assertThat(userCredentials.getActivateTokenExpTime()).isCloseTo(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(ttl), Offset.offset(120000L));
String initialActivationLink = getActivationLink(user);
String initialActivationToken = StringUtils.substringAfterLast(initialActivationLink, "activateToken=");
UserActivationLink activationLinkInfo = getActivationLinkInfo(user);
assertThat(TimeUnit.MILLISECONDS.toHours(activationLinkInfo.ttlMs())).isCloseTo(ttl, within(1L));
assertThat(activationLinkInfo.value()).isEqualTo(initialActivationLink);
// expiring activation token
userCredentials.setActivateTokenExpTime(System.currentTimeMillis() - 1);
userCredentialsDao.save(tenantId, userCredentials);
doGet("/api/noauth/activate?activateToken={activateToken}", initialActivationToken)
.andExpect(status().isSeeOther())
.andExpect(header().string(HttpHeaders.LOCATION, "/activationLinkExpired"));
doPost("/api/noauth/activate", JacksonUtil.newObjectNode()
.put("activateToken", initialActivationToken)
.put("password", "wefewe")).andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message", is("Activation token expired")));
// checking that activation link is regenerated when requested
UserActivationLink regeneratedActivationLink = getActivationLinkInfo(user);
assertThat(regeneratedActivationLink.value()).isNotEqualTo(initialActivationLink);
assertThat(TimeUnit.MILLISECONDS.toHours(regeneratedActivationLink.ttlMs())).isCloseTo(ttl, within(1L));
// checking link renewal if less than 15 minutes before expiration
userCredentials = userCredentialsDao.findByUserId(tenantId, user.getUuidId());
userCredentials.setActivateTokenExpTime(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(30));
userCredentialsDao.save(tenantId, userCredentials);
activationLinkInfo = getActivationLinkInfo(user);
assertThat(activationLinkInfo.value()).isEqualTo(regeneratedActivationLink.value());
assertThat(TimeUnit.MILLISECONDS.toMinutes(activationLinkInfo.ttlMs())).isCloseTo(30, within(1L));
userCredentials.setActivateTokenExpTime(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(10));
userCredentialsDao.save(tenantId, userCredentials);
UserActivationLink newActivationLink = getActivationLinkInfo(user);
assertThat(newActivationLink.value()).isNotEqualTo(regeneratedActivationLink.value());
assertThat(TimeUnit.MILLISECONDS.toHours(newActivationLink.ttlMs())).isCloseTo(ttl, within(1L));
String newActivationToken = StringUtils.substringAfterLast(newActivationLink.value(), "activateToken=");
userCredentials = userCredentialsDao.findByUserId(tenantId, user.getUuidId());
assertThat(userCredentials.getActivateTokenExpTime()).isCloseTo(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(ttl), Offset.offset(120000L));
doPost("/api/noauth/activate", JacksonUtil.newObjectNode()
.put("activateToken", newActivationToken)
.put("password", "wefewe")).andExpect(status().isOk());
} }
@Test @Test
@ -173,4 +270,19 @@ public class AuthControllerTest extends AbstractControllerTest {
doGet("/login").andExpect(status().isOk()); doGet("/login").andExpect(status().isOk());
doGet("/home").andExpect(status().isOk()); doGet("/home").andExpect(status().isOk());
} }
private void updateSecuritySettings(Consumer<SecuritySettings> updater) throws Exception {
SecuritySettings securitySettings = doGet("/api/admin/securitySettings", SecuritySettings.class);
updater.accept(securitySettings);
doPost("/api/admin/securitySettings", securitySettings).andExpect(status().isOk());
}
private String getActivationLink(User user) throws Exception {
return doGet("/api/user/" + user.getId() + "/activationLink", String.class);
}
private UserActivationLink getActivationLinkInfo(User user) throws Exception {
return doGet("/api/user/" + user.getId() + "/activationLinkInfo", UserActivationLink.class);
}
} }

25
application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java

@ -103,6 +103,7 @@ import org.thingsboard.server.gen.edge.v1.UserUpdateMsg;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Random;
import java.util.TreeMap; import java.util.TreeMap;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -124,6 +125,8 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest {
protected EdgeImitator edgeImitator; protected EdgeImitator edgeImitator;
protected Edge edge; protected Edge edge;
private Random random = new Random();
@Autowired @Autowired
protected EdgeEventService edgeEventService; protected EdgeEventService edgeEventService;
@ -163,15 +166,10 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest {
} }
private RuleChainId getEdgeRootRuleChainId() throws Exception { private RuleChainId getEdgeRootRuleChainId() throws Exception {
List<RuleChain> edgeRuleChains = doGetTypedWithPageLink("/api/edge/" + edge.getUuidId() + "/ruleChains?", return doGetTypedWithPageLink("/api/ruleChains?type={type}&", new TypeReference<PageData<RuleChain>>() {},
new TypeReference<PageData<RuleChain>>() { new PageLink(100, 0, "Edge Root Rule Chain"),
}, new PageLink(100)).getData(); "EDGE")
for (RuleChain edgeRuleChain : edgeRuleChains) { .getData().get(0).getId();
if (edgeRuleChain.isRoot()) {
return edgeRuleChain.getId();
}
}
throw new RuntimeException("Root rule chain not found");
} }
@After @After
@ -196,6 +194,8 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest {
Asset savedAsset = saveAsset("Edge Asset 1"); Asset savedAsset = saveAsset("Edge Asset 1");
updateRootRuleChainMetadata();
edge = doPost("/api/edge", constructEdge("Test Edge", "test"), Edge.class); edge = doPost("/api/edge", constructEdge("Test Edge", "test"), Edge.class);
doPost("/api/edge/" + edge.getUuidId() doPost("/api/edge/" + edge.getUuidId()
@ -207,6 +207,13 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest {
TimeUnit.MILLISECONDS.sleep(1000); TimeUnit.MILLISECONDS.sleep(1000);
} }
protected void updateRootRuleChainMetadata() throws Exception {
RuleChainId rootRuleChainId = getEdgeRootRuleChainId();
RuleChainMetaData rootRuleChainMetadata = doGet("/api/ruleChain/" + rootRuleChainId.getId().toString() + "/metadata", RuleChainMetaData.class);
rootRuleChainMetadata.getNodes().forEach(n -> n.setDebugMode(random.nextBoolean()));
doPost("/api/ruleChain/metadata", rootRuleChainMetadata, RuleChainMetaData.class);
}
protected void extendDeviceProfileData(DeviceProfile deviceProfile) { protected void extendDeviceProfileData(DeviceProfile deviceProfile) {
DeviceProfileData profileData = deviceProfile.getProfileData(); DeviceProfileData profileData = deviceProfile.getProfileData();
List<DeviceProfileAlarm> alarms = new ArrayList<>(); List<DeviceProfileAlarm> alarms = new ArrayList<>();

18
application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java

@ -31,6 +31,7 @@ import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.gen.edge.v1.DashboardUpdateMsg; import org.thingsboard.server.gen.edge.v1.DashboardUpdateMsg;
import org.thingsboard.server.gen.edge.v1.ResourceUpdateMsg;
import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.gen.edge.v1.UpdateMsgType;
import org.thingsboard.server.gen.edge.v1.UplinkMsg; import org.thingsboard.server.gen.edge.v1.UplinkMsg;
@ -44,12 +45,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
public class DashboardEdgeTest extends AbstractEdgeTest { public class DashboardEdgeTest extends AbstractEdgeTest {
private static final int MOBILE_ORDER = 5; private static final int MOBILE_ORDER = 5;
private static final String IMAGE = "data:image/png;base64,iVBORw0KGgoA"; private static final String IMAGE = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4Ij48cGF0aCBkPSJNMTMuMjMgMTAuNTZWMTBjLTEuOTQgMC0zLjk5LjM5LTMuOTkgMi42NyAwIDEuMTYuNjEgMS45NSAxLjYzIDEuOTUuNzYgMCAxLjQzLS40NyAxLjg2LTEuMjIuNTItLjkzLjUtMS44LjUtMi44NG0yLjcgNi41M2MtLjE4LjE2LS40My4xNy0uNjMuMDYtLjg5LS43NC0xLjA1LTEuMDgtMS41NC0xLjc5LTEuNDcgMS41LTIuNTEgMS45NS00LjQyIDEuOTUtMi4yNSAwLTQuMDEtMS4zOS00LjAxLTQuMTcgMC0yLjE4IDEuMTctMy42NCAyLjg2LTQuMzggMS40Ni0uNjQgMy40OS0uNzYgNS4wNC0uOTNWNy41YzAtLjY2LjA1LTEuNDEtLjMzLTEuOTYtLjMyLS40OS0uOTUtLjctMS41LS43LTEuMDIgMC0xLjkzLjUzLTIuMTUgMS42MS0uMDUuMjQtLjI1LjQ4LS40Ny40OWwtMi42LS4yOGMtLjIyLS4wNS0uNDYtLjIyLS40LS41Ni42LTMuMTUgMy40NS00LjEgNi00LjEgMS4zIDAgMyAuMzUgNC4wMyAxLjMzQzE3LjExIDQuNTUgMTcgNi4xOCAxNyA3Ljk1djQuMTdjMCAxLjI1LjUgMS44MSAxIDIuNDguMTcuMjUuMjEuNTQgMCAuNzFsLTIuMDYgMS43OGgtLjAxIj48L3BhdGg+PHBhdGggZD0iTTIwLjE2IDE5LjU0QzE4IDIxLjE0IDE0LjgyIDIyIDEyLjEgMjJjLTMuODEgMC03LjI1LTEuNDEtOS44NS0zLjc2LS4yLS4xOC0uMDItLjQzLjI1LS4yOSAyLjc4IDEuNjMgNi4yNSAyLjYxIDkuODMgMi42MSAyLjQxIDAgNS4wNy0uNSA3LjUxLTEuNTMuMzctLjE2LjY2LjI0LjMyLjUxIj48L3BhdGg+PHBhdGggZD0iTTIxLjA3IDE4LjVjLS4yOC0uMzYtMS44NS0uMTctMi41Ny0uMDgtLjE5LjAyLS4yMi0uMTYtLjAzLS4zIDEuMjQtLjg4IDMuMjktLjYyIDMuNTMtLjMzLjI0LjMtLjA3IDIuMzUtMS4yNCAzLjMyLS4xOC4xNi0uMzUuMDctLjI2LS4xMS4yNi0uNjcuODUtMi4xNC41Ny0yLjV6Ij48L3BhdGg+PC9zdmc+";
@Test @Test
public void testDashboards() throws Exception { public void testDashboards() throws Exception {
// create dashboard and assign to edge // create dashboard and assign to edge
edgeImitator.expectMessageAmount(1); edgeImitator.expectMessageAmount(2);
Dashboard dashboard = new Dashboard(); Dashboard dashboard = new Dashboard();
dashboard.setTitle("Edge Test Dashboard"); dashboard.setTitle("Edge Test Dashboard");
dashboard.setMobileHide(true); dashboard.setMobileHide(true);
@ -59,23 +60,26 @@ public class DashboardEdgeTest extends AbstractEdgeTest {
doPost("/api/edge/" + edge.getUuidId() doPost("/api/edge/" + edge.getUuidId()
+ "/dashboard/" + savedDashboard.getUuidId(), Dashboard.class); + "/dashboard/" + savedDashboard.getUuidId(), Dashboard.class);
Assert.assertTrue(edgeImitator.waitForMessages()); Assert.assertTrue(edgeImitator.waitForMessages());
AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Optional<DashboardUpdateMsg> dashboardUpdateMsgOpt = edgeImitator.findMessageByType(DashboardUpdateMsg.class);
Assert.assertTrue(latestMessage instanceof DashboardUpdateMsg); Assert.assertTrue(dashboardUpdateMsgOpt.isPresent());
DashboardUpdateMsg dashboardUpdateMsg = (DashboardUpdateMsg) latestMessage; DashboardUpdateMsg dashboardUpdateMsg = dashboardUpdateMsgOpt.get();
Dashboard dashboardMsg = JacksonUtil.fromString(dashboardUpdateMsg.getEntity(), Dashboard.class, true); Dashboard dashboardMsg = JacksonUtil.fromString(dashboardUpdateMsg.getEntity(), Dashboard.class, true);
Assert.assertNotNull(dashboardMsg); Assert.assertNotNull(dashboardMsg);
Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, dashboardUpdateMsg.getMsgType()); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, dashboardUpdateMsg.getMsgType());
Assert.assertEquals(savedDashboard, dashboardMsg); Assert.assertEquals(savedDashboard, dashboardMsg);
Assert.assertEquals(IMAGE, dashboardMsg.getImage()); Assert.assertEquals("tb-image;/api/images/tenant/edge_test_dashboard_dashboard_image.svg", dashboardMsg.getImage());
Assert.assertEquals(MOBILE_ORDER, dashboardMsg.getMobileOrder().intValue()); Assert.assertEquals(MOBILE_ORDER, dashboardMsg.getMobileOrder().intValue());
testAutoGeneratedCodeByProtobuf(dashboardUpdateMsg); testAutoGeneratedCodeByProtobuf(dashboardUpdateMsg);
Optional<ResourceUpdateMsg> resourceUpdateMsg = edgeImitator.findMessageByType(ResourceUpdateMsg.class);
Assert.assertTrue(resourceUpdateMsg.isPresent());
// update dashboard // update dashboard
edgeImitator.expectMessageAmount(1); edgeImitator.expectMessageAmount(1);
savedDashboard.setTitle("Updated Edge Test Dashboard"); savedDashboard.setTitle("Updated Edge Test Dashboard");
savedDashboard = doPost("/api/dashboard", savedDashboard, Dashboard.class); savedDashboard = doPost("/api/dashboard", savedDashboard, Dashboard.class);
Assert.assertTrue(edgeImitator.waitForMessages()); Assert.assertTrue(edgeImitator.waitForMessages());
latestMessage = edgeImitator.getLatestMessage(); AbstractMessage latestMessage = edgeImitator.getLatestMessage();
Assert.assertTrue(latestMessage instanceof DashboardUpdateMsg); Assert.assertTrue(latestMessage instanceof DashboardUpdateMsg);
dashboardUpdateMsg = (DashboardUpdateMsg) latestMessage; dashboardUpdateMsg = (DashboardUpdateMsg) latestMessage;
dashboardMsg = JacksonUtil.fromString(dashboardUpdateMsg.getEntity(), Dashboard.class, true); dashboardMsg = JacksonUtil.fromString(dashboardUpdateMsg.getEntity(), Dashboard.class, true);

12
application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java

@ -185,6 +185,18 @@ public class RuleChainEdgeTest extends AbstractEdgeTest {
return doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class); return doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class);
} }
@Test
public void testUpdateRootRuleChain() throws Exception {
edgeImitator.expectMessageAmount(2);
updateRootRuleChainMetadata();
Assert.assertTrue(edgeImitator.waitForMessages());
Optional<RuleChainUpdateMsg> ruleChainUpdateMsgOpt = edgeImitator.findMessageByType(RuleChainUpdateMsg.class);
Assert.assertTrue(ruleChainUpdateMsgOpt.isPresent());
Optional<RuleChainMetadataUpdateMsg> ruleChainMetadataUpdateMsgOpt = edgeImitator.findMessageByType(RuleChainMetadataUpdateMsg.class);
Assert.assertTrue(ruleChainMetadataUpdateMsgOpt.isPresent());
}
@Test @Test
public void testSetRootRuleChain() throws Exception { public void testSetRootRuleChain() throws Exception {
// create rule chain // create rule chain

4
common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java

@ -59,6 +59,10 @@ public interface UserService extends EntityDaoService {
UserCredentials requestExpiredPasswordReset(TenantId tenantId, UserCredentialsId userCredentialsId); UserCredentials requestExpiredPasswordReset(TenantId tenantId, UserCredentialsId userCredentialsId);
UserCredentials generatePasswordResetToken(UserCredentials userCredentials);
UserCredentials generateUserActivationToken(UserCredentials userCredentials);
UserCredentials replaceUserCredentials(TenantId tenantId, UserCredentials userCredentials); UserCredentials replaceUserCredentials(TenantId tenantId, UserCredentials userCredentials);
void deleteUser(TenantId tenantId, User user); void deleteUser(TenantId tenantId, User user);

19
common/data/src/main/java/org/thingsboard/server/common/data/UserActivationLink.java

@ -0,0 +1,19 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data;
public record UserActivationLink(String value, long ttlMs) {
}

104
common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java

@ -16,41 +16,31 @@
package org.thingsboard.server.common.data.security; package org.thingsboard.server.common.data.security;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.JsonNode; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import org.thingsboard.server.common.data.BaseData; import lombok.ToString;
import org.thingsboard.server.common.data.BaseDataWithAdditionalInfo;
import org.thingsboard.server.common.data.id.UserCredentialsId; import org.thingsboard.server.common.data.id.UserCredentialsId;
import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.validation.NoXss;
import static org.thingsboard.server.common.data.BaseDataWithAdditionalInfo.getJson; import java.io.Serial;
import static org.thingsboard.server.common.data.BaseDataWithAdditionalInfo.setJson;
@Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class UserCredentials extends BaseData<UserCredentialsId> { @ToString(callSuper = true)
public class UserCredentials extends BaseDataWithAdditionalInfo<UserCredentialsId> {
@Serial
private static final long serialVersionUID = -2108436378880529163L; private static final long serialVersionUID = -2108436378880529163L;
private UserId userId; private UserId userId;
private boolean enabled; private boolean enabled;
private String password; private String password;
private String activateToken; private String activateToken;
private Long activateTokenExpTime;
private String resetToken; private String resetToken;
private Long resetTokenExpTime;
@NoXss
private transient JsonNode additionalInfo;
@JsonIgnore
private byte[] additionalInfoBytes;
public JsonNode getAdditionalInfo() {
return getJson(() -> additionalInfo, () -> additionalInfoBytes);
}
public void setAdditionalInfo(JsonNode settings) {
setJson(settings, json -> this.additionalInfo = json, bytes -> this.additionalInfoBytes = bytes);
}
public UserCredentials() { public UserCredentials() {
super(); super();
} }
@ -59,75 +49,25 @@ public class UserCredentials extends BaseData<UserCredentialsId> {
super(id); super(id);
} }
public UserCredentials(UserCredentials userCredentials) {
super(userCredentials);
this.userId = userCredentials.getUserId();
this.password = userCredentials.getPassword();
this.enabled = userCredentials.isEnabled();
this.activateToken = userCredentials.getActivateToken();
this.resetToken = userCredentials.getResetToken();
setAdditionalInfo(userCredentials.getAdditionalInfo());
}
public UserId getUserId() {
return userId;
}
public void setUserId(UserId userId) {
this.userId = userId;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getActivateToken() { @JsonIgnore
return activateToken; public boolean isActivationTokenExpired() {
return getActivationTokenTtl() == 0;
} }
public void setActivateToken(String activateToken) { @JsonIgnore
this.activateToken = activateToken; public long getActivationTokenTtl() {
} return activateTokenExpTime != null ? Math.max(activateTokenExpTime - System.currentTimeMillis(), 0) : 0;
public String getResetToken() {
return resetToken;
} }
public void setResetToken(String resetToken) { @JsonIgnore
this.resetToken = resetToken; public boolean isResetTokenExpired() {
return getResetTokenTtl() == 0;
} }
@Override @JsonIgnore
public String toString() { public long getResetTokenTtl() {
StringBuilder builder = new StringBuilder(); return resetTokenExpTime != null ? Math.max(resetTokenExpTime - System.currentTimeMillis(), 0) : 0;
builder.append("UserCredentials [userId=");
builder.append(userId);
builder.append(", enabled=");
builder.append(enabled);
builder.append(", password=");
builder.append(password);
builder.append(", activateToken=");
builder.append(activateToken);
builder.append(", resetToken=");
builder.append(resetToken);
builder.append(", createdTime=");
builder.append(createdTime);
builder.append(", id=");
builder.append(id);
builder.append("]");
return builder.toString();
} }
} }

25
common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java

@ -16,22 +16,39 @@
package org.thingsboard.server.common.data.security.model; package org.thingsboard.server.common.data.security.model;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data; import lombok.Data;
import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
@Schema @Schema
@Data @Data
public class SecuritySettings implements Serializable { public class SecuritySettings implements Serializable {
@Serial
private static final long serialVersionUID = -1307613974597312465L; private static final long serialVersionUID = -1307613974597312465L;
@Schema(description = "The user password policy object." ) @Schema(description = "The user password policy object.")
private UserPasswordPolicy passwordPolicy; private UserPasswordPolicy passwordPolicy;
@Schema(description = "Maximum number of failed login attempts allowed before user account is locked." )
@Schema(description = "Maximum number of failed login attempts allowed before user account is locked.")
private Integer maxFailedLoginAttempts; private Integer maxFailedLoginAttempts;
@Schema(description = "Email to use for notifications about locked users." )
@Schema(description = "Email to use for notifications about locked users.")
private String userLockoutNotificationEmail; private String userLockoutNotificationEmail;
@Schema(description = "Mobile secret key length" )
@Schema(description = "Mobile secret key length")
private Integer mobileSecretKeyLength; private Integer mobileSecretKeyLength;
@NotNull @Min(1) @Max(24)
@Schema(description = "TTL in hours for user activation link", minimum = "1", maximum = "24", requiredMode = Schema.RequiredMode.REQUIRED)
private Integer userActivationTokenTtl;
@NotNull @Min(1) @Max(24)
@Schema(description = "TTL in hours for password reset link", minimum = "1", maximum = "24", requiredMode = Schema.RequiredMode.REQUIRED)
private Integer passwordResetTokenTtl;
} }

19
common/queue/src/main/java/org/thingsboard/server/queue/scheduler/DefaultSchedulerComponent.java

@ -17,6 +17,7 @@ package org.thingsboard.server.queue.scheduler;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy; import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.common.util.ThingsBoardThreadFactory;
@ -26,14 +27,15 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@Slf4j
@Component @Component
public class DefaultSchedulerComponent implements SchedulerComponent { public class DefaultSchedulerComponent implements SchedulerComponent {
protected ScheduledExecutorService schedulerExecutor; private ScheduledExecutorService schedulerExecutor;
@PostConstruct @PostConstruct
public void init() { public void init() {
this.schedulerExecutor = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("queue-scheduler")); schedulerExecutor = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("queue-scheduler"));
} }
@PreDestroy @PreDestroy
@ -52,10 +54,19 @@ public class DefaultSchedulerComponent implements SchedulerComponent {
} }
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit) { public ScheduledFuture<?> scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit) {
return schedulerExecutor.scheduleAtFixedRate(command, initialDelay, period, unit); return schedulerExecutor.scheduleAtFixedRate(() -> runSafely(command), initialDelay, period, unit);
} }
public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit) { public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit) {
return schedulerExecutor.scheduleWithFixedDelay(command, initialDelay, delay, unit); return schedulerExecutor.scheduleWithFixedDelay(() -> runSafely(command), initialDelay, delay, unit);
} }
private void runSafely(Runnable command) {
try {
command.run();
} catch (Throwable t) {
log.error("Unexpected error occurred while executing task!", t);
}
}
} }

95
common/queue/src/test/java/org/thingsboard/server/queue/scheduler/DefaultSchedulerComponentTest.java

@ -0,0 +1,95 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.queue.scheduler;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.assertj.core.api.Assertions.assertThat;
class DefaultSchedulerComponentTest {
DefaultSchedulerComponent schedulerComponent;
@BeforeEach
void setup() {
schedulerComponent = new DefaultSchedulerComponent();
schedulerComponent.init();
}
@AfterEach
void cleanup() {
schedulerComponent.destroy();
}
@Test
@DisplayName("scheduleAtFixedRate() should continue periodic execution even if command throws exception")
void scheduleAtFixedRateShouldNotStopPeriodicExecutionWhenCommandThrowsException() {
// GIVEN
var wasExecutedAtLeastOnce = new AtomicBoolean(false);
Runnable exceptionThrowingCommand = () -> {
try {
throw new RuntimeException("Unexpected exception");
} finally {
wasExecutedAtLeastOnce.set(true);
}
};
// WHEN
ScheduledFuture<?> future = schedulerComponent.scheduleAtFixedRate(exceptionThrowingCommand, 0, 200, TimeUnit.MILLISECONDS);
// THEN
Awaitility.await().alias("Wait until command is executed at least once")
.atMost(5, TimeUnit.SECONDS)
.until(wasExecutedAtLeastOnce::get);
assertThat(future.isDone()).as("Periodic execution should not stop after unhandled exception is thrown by the command").isFalse();
}
@Test
@DisplayName("scheduleWithFixedDelay() should continue periodic execution even if command throws exception")
void scheduleWithFixedDelayShouldNotStopPeriodicExecutionWhenCommandThrowsException() {
// GIVEN
var wasExecutedAtLeastOnce = new AtomicBoolean(false);
Runnable exceptionThrowingCommand = () -> {
try {
throw new RuntimeException("Unexpected exception");
} finally {
wasExecutedAtLeastOnce.set(true);
}
};
// WHEN
ScheduledFuture<?> future = schedulerComponent.scheduleWithFixedDelay(exceptionThrowingCommand, 0, 200, TimeUnit.MILLISECONDS);
// THEN
Awaitility.await().alias("Wait until command is executed at least once")
.atMost(5, TimeUnit.SECONDS)
.until(wasExecutedAtLeastOnce::get);
assertThat(future.isDone()).as("Periodic execution should not stop after unhandled exception is thrown by the command").isFalse();
}
}

97
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/activity/AbstractActivityManager.java

@ -69,7 +69,7 @@ public abstract class AbstractActivityManager<Key, Metadata> implements Activity
log.error("Failed to process activity event: provided activity key is null."); log.error("Failed to process activity event: provided activity key is null.");
return; return;
} }
log.debug("Received activity event for key: [{}]", key); log.debug("Received activity event for key: [{}]. Event time: [{}].", key, newLastRecordedTime);
var shouldReport = new AtomicBoolean(false); var shouldReport = new AtomicBoolean(false);
var lastRecordedTime = new AtomicLong(); var lastRecordedTime = new AtomicLong();
@ -94,7 +94,7 @@ public abstract class AbstractActivityManager<Key, Metadata> implements Activity
}); });
if (shouldReport.get() && lastReportedTime.get() < lastRecordedTime.get()) { if (shouldReport.get() && lastReportedTime.get() < lastRecordedTime.get()) {
log.debug("Going to report first activity event for key: [{}].", key); log.debug("Going to report first activity event for key: [{}]. Event time: [{}].", key, lastRecordedTime.get());
reportActivity(key, metadata, lastRecordedTime.get(), new ActivityReportCallback<>() { reportActivity(key, metadata, lastRecordedTime.get(), new ActivityReportCallback<>() {
@Override @Override
public void onSuccess(Key key, long reportedTime) { public void onSuccess(Key key, long reportedTime) {
@ -103,7 +103,7 @@ public abstract class AbstractActivityManager<Key, Metadata> implements Activity
@Override @Override
public void onFailure(Key key, Throwable t) { public void onFailure(Key key, Throwable t) {
log.debug("Failed to report first activity event for key: [{}].", key, t); log.debug("Failed to report first activity event for key: [{}]. Event time: [{}].", key, lastRecordedTime.get(), t);
} }
}); });
} }
@ -113,50 +113,59 @@ public abstract class AbstractActivityManager<Key, Metadata> implements Activity
public void onReportingPeriodEnd() { public void onReportingPeriodEnd() {
log.debug("Going to end reporting period."); log.debug("Going to end reporting period.");
for (Map.Entry<Key, ActivityStateWrapper> entry : states.entrySet()) { for (Map.Entry<Key, ActivityStateWrapper> entry : states.entrySet()) {
var key = entry.getKey(); Key key = entry.getKey();
var stateWrapper = entry.getValue(); ActivityStateWrapper stateWrapper = entry.getValue();
var currentState = stateWrapper.getState(); try {
reportLastEvent(key, stateWrapper);
long lastRecordedTime = currentState.getLastRecordedTime(); } catch (Exception e) {
long lastReportedTime = stateWrapper.getLastReportedTime(); log.error("Failed to report last activity event on reporting period end for key: [{}]. State: [{}].", key, stateWrapper, e);
var metadata = currentState.getMetadata();
boolean hasExpired;
boolean shouldReport;
var updatedState = updateState(key, currentState);
if (updatedState != null) {
stateWrapper.setState(updatedState);
lastRecordedTime = updatedState.getLastRecordedTime();
metadata = updatedState.getMetadata();
hasExpired = hasExpired(lastRecordedTime);
shouldReport = stateWrapper.getStrategy().onReportingPeriodEnd();
} else {
states.remove(key);
hasExpired = false;
shouldReport = true;
} }
}
}
if (hasExpired) { private void reportLastEvent(Key key, ActivityStateWrapper stateWrapper) {
states.remove(key); var currentState = stateWrapper.getState();
onStateExpiry(key, metadata);
shouldReport = true; long lastRecordedTime = currentState.getLastRecordedTime();
} long lastReportedTime = stateWrapper.getLastReportedTime();
var metadata = currentState.getMetadata();
boolean hasExpired;
boolean shouldReport;
var updatedState = updateState(key, currentState);
if (updatedState != null) {
stateWrapper.setState(updatedState);
lastRecordedTime = updatedState.getLastRecordedTime();
metadata = updatedState.getMetadata();
hasExpired = hasExpired(lastRecordedTime);
shouldReport = stateWrapper.getStrategy().onReportingPeriodEnd();
} else {
states.remove(key);
hasExpired = false;
shouldReport = true;
}
if (shouldReport && lastReportedTime < lastRecordedTime) { if (hasExpired) {
log.debug("Going to report last activity event for key: [{}].", key); states.remove(key);
reportActivity(key, metadata, lastRecordedTime, new ActivityReportCallback<>() { onStateExpiry(key, metadata);
@Override shouldReport = true;
public void onSuccess(Key key, long reportedTime) { }
updateLastReportedTime(key, reportedTime);
} if (shouldReport && lastReportedTime < lastRecordedTime) {
long timeToReport = lastRecordedTime;
@Override log.debug("Going to report last activity event for key: [{}]. Event time: [{}].", key, timeToReport);
public void onFailure(Key key, Throwable t) { reportActivity(key, metadata, timeToReport, new ActivityReportCallback<>() {
log.debug("Failed to report last activity event for key: [{}].", key, t); @Override
} public void onSuccess(Key key, long reportedTime) {
}); updateLastReportedTime(key, reportedTime);
} }
@Override
public void onFailure(Key key, Throwable t) {
log.debug("Failed to report last activity event for key: [{}]. Event time: [{}].", key, timeToReport, t);
}
});
} }
} }

2
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/activity/strategy/FirstAndLastEventActivityStrategy.java

@ -16,7 +16,9 @@
package org.thingsboard.server.common.transport.activity.strategy; package org.thingsboard.server.common.transport.activity.strategy;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.ToString;
@ToString
@EqualsAndHashCode @EqualsAndHashCode
public final class FirstAndLastEventActivityStrategy implements ActivityStrategy { public final class FirstAndLastEventActivityStrategy implements ActivityStrategy {

2
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/activity/strategy/FirstEventActivityStrategy.java

@ -16,7 +16,9 @@
package org.thingsboard.server.common.transport.activity.strategy; package org.thingsboard.server.common.transport.activity.strategy;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.ToString;
@ToString
@EqualsAndHashCode @EqualsAndHashCode
public final class FirstEventActivityStrategy implements ActivityStrategy { public final class FirstEventActivityStrategy implements ActivityStrategy {

10
common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java

@ -38,6 +38,7 @@ import org.thingsboard.server.common.data.kv.KvEntry;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.io.Reader; import java.io.Reader;
import java.io.Writer; import java.io.Writer;
import java.nio.file.Files; import java.nio.file.Files;
@ -259,6 +260,15 @@ public class JacksonUtil {
} }
} }
public static JsonNode toJsonNode(InputStream value) {
try {
return value != null ? OBJECT_MAPPER.readTree(value) : null;
} catch (IOException e) {
throw new IllegalArgumentException("The given InputStream value: "
+ value + " cannot be transformed to a JsonNode", e);
}
}
public static ObjectNode newObjectNode() { public static ObjectNode newObjectNode() {
return newObjectNode(OBJECT_MAPPER); return newObjectNode(OBJECT_MAPPER);
} }

4
dao/src/main/java/org/thingsboard/server/dao/edge/BaseRelatedEdgesService.java

@ -15,6 +15,7 @@
*/ */
package org.thingsboard.server.dao.edge; package org.thingsboard.server.dao.edge;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -30,6 +31,7 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.dao.entity.AbstractCachedEntityService; import org.thingsboard.server.dao.entity.AbstractCachedEntityService;
@Service @Service
@Slf4j
public class BaseRelatedEdgesService extends AbstractCachedEntityService<RelatedEdgesCacheKey, RelatedEdgesCacheValue, RelatedEdgesEvictEvent> implements RelatedEdgesService { public class BaseRelatedEdgesService extends AbstractCachedEntityService<RelatedEdgesCacheKey, RelatedEdgesCacheValue, RelatedEdgesEvictEvent> implements RelatedEdgesService {
public static final int RELATED_EDGES_CACHE_ITEMS = 1000; public static final int RELATED_EDGES_CACHE_ITEMS = 1000;
@ -47,6 +49,7 @@ public class BaseRelatedEdgesService extends AbstractCachedEntityService<Related
@Override @Override
public PageData<EdgeId> findEdgeIdsByEntityId(TenantId tenantId, EntityId entityId, PageLink pageLink) { public PageData<EdgeId> findEdgeIdsByEntityId(TenantId tenantId, EntityId entityId, PageLink pageLink) {
log.trace("Executing findEdgeIdsByEntityId, tenantId [{}], entityId [{}], pageLink [{}]", tenantId, entityId, pageLink);
if (!pageLink.equals(FIRST_PAGE)) { if (!pageLink.equals(FIRST_PAGE)) {
return edgeService.findEdgeIdsByTenantIdAndEntityId(tenantId, entityId, pageLink); return edgeService.findEdgeIdsByTenantIdAndEntityId(tenantId, entityId, pageLink);
} }
@ -56,6 +59,7 @@ public class BaseRelatedEdgesService extends AbstractCachedEntityService<Related
@Override @Override
public void publishRelatedEdgeIdsEvictEvent(TenantId tenantId, EntityId entityId) { public void publishRelatedEdgeIdsEvictEvent(TenantId tenantId, EntityId entityId) {
log.trace("Executing publishRelatedEdgeIdsEvictEvent, tenantId [{}], entityId [{}]", tenantId, entityId);
publishEvictEvent(new RelatedEdgesEvictEvent(tenantId, entityId)); publishEvictEvent(new RelatedEdgesEvictEvent(tenantId, entityId));
} }

2
dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java

@ -79,7 +79,9 @@ public class ModelConstants {
public static final String USER_CREDENTIALS_ENABLED_PROPERTY = "enabled"; public static final String USER_CREDENTIALS_ENABLED_PROPERTY = "enabled";
public static final String USER_CREDENTIALS_PASSWORD_PROPERTY = "password"; //NOSONAR, the constant used to identify password column name (not password value itself) public static final String USER_CREDENTIALS_PASSWORD_PROPERTY = "password"; //NOSONAR, the constant used to identify password column name (not password value itself)
public static final String USER_CREDENTIALS_ACTIVATE_TOKEN_PROPERTY = "activate_token"; public static final String USER_CREDENTIALS_ACTIVATE_TOKEN_PROPERTY = "activate_token";
public static final String USER_CREDENTIALS_ACTIVATE_TOKEN_EXP_TIME_PROPERTY = "activate_token_exp_time";
public static final String USER_CREDENTIALS_RESET_TOKEN_PROPERTY = "reset_token"; public static final String USER_CREDENTIALS_RESET_TOKEN_PROPERTY = "reset_token";
public static final String USER_CREDENTIALS_RESET_TOKEN_EXP_TIME_PROPERTY = "reset_token_exp_time";
public static final String USER_CREDENTIALS_ADDITIONAL_PROPERTY = "additional_info"; public static final String USER_CREDENTIALS_ADDITIONAL_PROPERTY = "additional_info";
/** /**

10
dao/src/main/java/org/thingsboard/server/dao/model/sql/UserCredentialsEntity.java

@ -50,9 +50,15 @@ public final class UserCredentialsEntity extends BaseSqlEntity<UserCredentials>
@Column(name = ModelConstants.USER_CREDENTIALS_ACTIVATE_TOKEN_PROPERTY, unique = true) @Column(name = ModelConstants.USER_CREDENTIALS_ACTIVATE_TOKEN_PROPERTY, unique = true)
private String activateToken; private String activateToken;
@Column(name = ModelConstants.USER_CREDENTIALS_ACTIVATE_TOKEN_EXP_TIME_PROPERTY)
private Long activateTokenExpTime;
@Column(name = ModelConstants.USER_CREDENTIALS_RESET_TOKEN_PROPERTY, unique = true) @Column(name = ModelConstants.USER_CREDENTIALS_RESET_TOKEN_PROPERTY, unique = true)
private String resetToken; private String resetToken;
@Column(name = ModelConstants.USER_CREDENTIALS_RESET_TOKEN_EXP_TIME_PROPERTY)
private Long resetTokenExpTime;
@Convert(converter = JsonConverter.class) @Convert(converter = JsonConverter.class)
@Column(name = ModelConstants.USER_CREDENTIALS_ADDITIONAL_PROPERTY) @Column(name = ModelConstants.USER_CREDENTIALS_ADDITIONAL_PROPERTY)
private JsonNode additionalInfo; private JsonNode additionalInfo;
@ -72,7 +78,9 @@ public final class UserCredentialsEntity extends BaseSqlEntity<UserCredentials>
this.enabled = userCredentials.isEnabled(); this.enabled = userCredentials.isEnabled();
this.password = userCredentials.getPassword(); this.password = userCredentials.getPassword();
this.activateToken = userCredentials.getActivateToken(); this.activateToken = userCredentials.getActivateToken();
this.activateTokenExpTime = userCredentials.getActivateTokenExpTime();
this.resetToken = userCredentials.getResetToken(); this.resetToken = userCredentials.getResetToken();
this.resetTokenExpTime = userCredentials.getResetTokenExpTime();
this.additionalInfo = userCredentials.getAdditionalInfo(); this.additionalInfo = userCredentials.getAdditionalInfo();
} }
@ -86,7 +94,9 @@ public final class UserCredentialsEntity extends BaseSqlEntity<UserCredentials>
userCredentials.setEnabled(enabled); userCredentials.setEnabled(enabled);
userCredentials.setPassword(password); userCredentials.setPassword(password);
userCredentials.setActivateToken(activateToken); userCredentials.setActivateToken(activateToken);
userCredentials.setActivateTokenExpTime(activateTokenExpTime);
userCredentials.setResetToken(resetToken); userCredentials.setResetToken(resetToken);
userCredentials.setResetTokenExpTime(resetTokenExpTime);
userCredentials.setAdditionalInfo(additionalInfo); userCredentials.setAdditionalInfo(additionalInfo);
return userCredentials; return userCredentials;
} }

6
dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java

@ -640,10 +640,8 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC
log.warn("[{}] Failed to create ruleChain relation. Edge Id: [{}]", ruleChainId, edgeId); log.warn("[{}] Failed to create ruleChain relation. Edge Id: [{}]", ruleChainId, edgeId);
throw new RuntimeException(e); throw new RuntimeException(e);
} }
if (!ruleChainId.equals(edge.getRootRuleChainId())) { eventPublisher.publishEvent(ActionEntityEvent.builder().tenantId(tenantId).edgeId(edgeId).entityId(ruleChainId)
eventPublisher.publishEvent(ActionEntityEvent.builder().tenantId(tenantId).edgeId(edgeId).entityId(ruleChainId) .actionType(ActionType.ASSIGNED_TO_EDGE).body(JacksonUtil.toString(edge)).build());
.actionType(ActionType.ASSIGNED_TO_EDGE).build());
}
return ruleChain; return ruleChain;
} }

81
dao/src/main/java/org/thingsboard/server/dao/settings/DefaultSecuritySettingsService.java

@ -0,0 +1,81 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.dao.settings;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.security.model.SecuritySettings;
import org.thingsboard.server.common.data.security.model.UserPasswordPolicy;
import org.thingsboard.server.dao.service.ConstraintValidator;
import static org.thingsboard.server.common.data.CacheConstants.SECURITY_SETTINGS_CACHE;
@Service
@RequiredArgsConstructor
public class DefaultSecuritySettingsService implements SecuritySettingsService {
private final AdminSettingsService adminSettingsService;
public static final int DEFAULT_MOBILE_SECRET_KEY_LENGTH = 64;
@Cacheable(cacheNames = SECURITY_SETTINGS_CACHE, key = "'securitySettings'")
@Override
public SecuritySettings getSecuritySettings() {
AdminSettings adminSettings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "securitySettings");
SecuritySettings securitySettings;
if (adminSettings != null) {
try {
securitySettings = JacksonUtil.convertValue(adminSettings.getJsonValue(), SecuritySettings.class);
} catch (Exception e) {
throw new RuntimeException("Failed to load security settings!", e);
}
} else {
securitySettings = new SecuritySettings();
securitySettings.setPasswordPolicy(new UserPasswordPolicy());
securitySettings.getPasswordPolicy().setMinimumLength(6);
securitySettings.getPasswordPolicy().setMaximumLength(72);
securitySettings.setMobileSecretKeyLength(DEFAULT_MOBILE_SECRET_KEY_LENGTH);
securitySettings.setPasswordResetTokenTtl(24);
securitySettings.setUserActivationTokenTtl(24);
}
return securitySettings;
}
@CacheEvict(cacheNames = SECURITY_SETTINGS_CACHE, key = "'securitySettings'")
@Override
public SecuritySettings saveSecuritySettings(SecuritySettings securitySettings) {
ConstraintValidator.validateFields(securitySettings);
AdminSettings adminSettings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "securitySettings");
if (adminSettings == null) {
adminSettings = new AdminSettings();
adminSettings.setTenantId(TenantId.SYS_TENANT_ID);
adminSettings.setKey("securitySettings");
}
adminSettings.setJsonValue(JacksonUtil.valueToTree(securitySettings));
AdminSettings savedAdminSettings = adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, adminSettings);
try {
return JacksonUtil.convertValue(savedAdminSettings.getJsonValue(), SecuritySettings.class);
} catch (Exception e) {
throw new RuntimeException("Failed to load security settings!", e);
}
}
}

26
dao/src/main/java/org/thingsboard/server/dao/settings/SecuritySettingsService.java

@ -0,0 +1,26 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.dao.settings;
import org.thingsboard.server.common.data.security.model.SecuritySettings;
public interface SecuritySettingsService {
SecuritySettings getSecuritySettings();
SecuritySettings saveSecuritySettings(SecuritySettings securitySettings);
}

29
dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java

@ -63,6 +63,7 @@ import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent;
import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.exception.IncorrectParameterException;
import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.service.PaginatedRemover; import org.thingsboard.server.dao.service.PaginatedRemover;
import org.thingsboard.server.dao.settings.SecuritySettingsService;
import org.thingsboard.server.dao.sql.JpaExecutorService; import org.thingsboard.server.dao.sql.JpaExecutorService;
import java.util.ArrayList; import java.util.ArrayList;
@ -72,6 +73,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.TimeUnit;
import static org.thingsboard.server.common.data.StringUtils.generateSafeToken; import static org.thingsboard.server.common.data.StringUtils.generateSafeToken;
import static org.thingsboard.server.dao.service.Validator.validateId; import static org.thingsboard.server.dao.service.Validator.validateId;
@ -102,6 +104,7 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
private final UserAuthSettingsDao userAuthSettingsDao; private final UserAuthSettingsDao userAuthSettingsDao;
private final UserSettingsService userSettingsService; private final UserSettingsService userSettingsService;
private final UserSettingsDao userSettingsDao; private final UserSettingsDao userSettingsDao;
private final SecuritySettingsService securitySettingsService;
private final DataValidator<User> userValidator; private final DataValidator<User> userValidator;
private final DataValidator<UserCredentials> userCredentialsValidator; private final DataValidator<UserCredentials> userCredentialsValidator;
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
@ -178,9 +181,9 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
countService.publishCountEntityEvictEvent(savedUser.getTenantId(), EntityType.USER); countService.publishCountEntityEvictEvent(savedUser.getTenantId(), EntityType.USER);
UserCredentials userCredentials = new UserCredentials(); UserCredentials userCredentials = new UserCredentials();
userCredentials.setEnabled(false); userCredentials.setEnabled(false);
userCredentials.setActivateToken(generateSafeToken(DEFAULT_TOKEN_LENGTH));
userCredentials.setUserId(new UserId(savedUser.getUuidId())); userCredentials.setUserId(new UserId(savedUser.getUuidId()));
userCredentials.setAdditionalInfo(JacksonUtil.newObjectNode()); userCredentials.setAdditionalInfo(JacksonUtil.newObjectNode());
userCredentials = generateUserActivationToken(userCredentials);
userCredentialsDao.save(user.getTenantId(), userCredentials); userCredentialsDao.save(user.getTenantId(), userCredentials);
} }
eventPublisher.publishEvent(SaveEntityEvent.builder() eventPublisher.publishEvent(SaveEntityEvent.builder()
@ -242,8 +245,12 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
if (userCredentials.isEnabled()) { if (userCredentials.isEnabled()) {
throw new IncorrectParameterException("User credentials already activated"); throw new IncorrectParameterException("User credentials already activated");
} }
if (userCredentials.isActivationTokenExpired()) {
throw new IncorrectParameterException("Activation token expired");
}
userCredentials.setEnabled(true); userCredentials.setEnabled(true);
userCredentials.setActivateToken(null); userCredentials.setActivateToken(null);
userCredentials.setActivateTokenExpTime(null);
userCredentials.setPassword(password); userCredentials.setPassword(password);
if (userCredentials.getPassword() != null) { if (userCredentials.getPassword() != null) {
updatePasswordHistory(userCredentials); updatePasswordHistory(userCredentials);
@ -263,7 +270,7 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
if (!userCredentials.isEnabled()) { if (!userCredentials.isEnabled()) {
throw new DisabledException(String.format("User credentials not enabled [%s]", email)); throw new DisabledException(String.format("User credentials not enabled [%s]", email));
} }
userCredentials.setResetToken(generateSafeToken(DEFAULT_TOKEN_LENGTH)); userCredentials = generatePasswordResetToken(userCredentials);
return saveUserCredentials(tenantId, userCredentials); return saveUserCredentials(tenantId, userCredentials);
} }
@ -273,10 +280,26 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
if (!userCredentials.isEnabled()) { if (!userCredentials.isEnabled()) {
throw new IncorrectParameterException("Unable to reset password for inactive user"); throw new IncorrectParameterException("Unable to reset password for inactive user");
} }
userCredentials.setResetToken(generateSafeToken(DEFAULT_TOKEN_LENGTH)); userCredentials = generatePasswordResetToken(userCredentials);
return saveUserCredentials(tenantId, userCredentials); return saveUserCredentials(tenantId, userCredentials);
} }
@Override
public UserCredentials generatePasswordResetToken(UserCredentials userCredentials) {
userCredentials.setResetToken(generateSafeToken(DEFAULT_TOKEN_LENGTH));
int ttlHours = securitySettingsService.getSecuritySettings().getPasswordResetTokenTtl();
userCredentials.setResetTokenExpTime(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(ttlHours));
return userCredentials;
}
@Override
public UserCredentials generateUserActivationToken(UserCredentials userCredentials) {
userCredentials.setActivateToken(generateSafeToken(DEFAULT_TOKEN_LENGTH));
int ttlHours = securitySettingsService.getSecuritySettings().getUserActivationTokenTtl();
userCredentials.setActivateTokenExpTime(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(ttlHours));
return userCredentials;
}
@Override @Override
public UserCredentials replaceUserCredentials(TenantId tenantId, UserCredentials userCredentials) { public UserCredentials replaceUserCredentials(TenantId tenantId, UserCredentials userCredentials) {
log.trace("Executing replaceUserCredentials [{}]", userCredentials); log.trace("Executing replaceUserCredentials [{}]", userCredentials);

2
dao/src/main/resources/sql/schema-entities.sql

@ -491,9 +491,11 @@ CREATE TABLE IF NOT EXISTS user_credentials (
id uuid NOT NULL CONSTRAINT user_credentials_pkey PRIMARY KEY, id uuid NOT NULL CONSTRAINT user_credentials_pkey PRIMARY KEY,
created_time bigint NOT NULL, created_time bigint NOT NULL,
activate_token varchar(255) UNIQUE, activate_token varchar(255) UNIQUE,
activate_token_exp_time BIGINT,
enabled boolean, enabled boolean,
password varchar(255), password varchar(255),
reset_token varchar(255) UNIQUE, reset_token varchar(255) UNIQUE,
reset_token_exp_time BIGINT,
user_id uuid UNIQUE, user_id uuid UNIQUE,
additional_info varchar DEFAULT '{}' additional_info varchar DEFAULT '{}'
); );

2
dao/src/test/java/org/thingsboard/server/dao/sql/user/JpaUserCredentialsDaoTest.java

@ -63,7 +63,9 @@ public class JpaUserCredentialsDaoTest extends AbstractJpaDaoTest {
userCredentials.setUserId(new UserId(UUID.randomUUID())); userCredentials.setUserId(new UserId(UUID.randomUUID()));
userCredentials.setPassword("password"); userCredentials.setPassword("password");
userCredentials.setActivateToken("ACTIVATE_TOKEN_" + number); userCredentials.setActivateToken("ACTIVATE_TOKEN_" + number);
userCredentials.setActivateTokenExpTime(123L);
userCredentials.setResetToken("RESET_TOKEN_" + number); userCredentials.setResetToken("RESET_TOKEN_" + number);
userCredentials.setResetTokenExpTime(321L);
return userCredentialsDao.save(SYSTEM_TENANT_ID, userCredentials); return userCredentialsDao.save(SYSTEM_TENANT_ID, userCredentials);
} }

6
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MailService.java

@ -32,13 +32,13 @@ public interface MailService {
void sendTestMail(JsonNode config, String email) throws ThingsboardException; void sendTestMail(JsonNode config, String email) throws ThingsboardException;
void sendActivationEmail(String activationLink, String email) throws ThingsboardException; void sendActivationEmail(String activationLink, long ttlMs, String email) throws ThingsboardException;
void sendAccountActivatedEmail(String loginLink, String email) throws ThingsboardException; void sendAccountActivatedEmail(String loginLink, String email) throws ThingsboardException;
void sendResetPasswordEmail(String passwordResetLink, String email) throws ThingsboardException; void sendResetPasswordEmail(String passwordResetLink, long ttlMs, String email) throws ThingsboardException;
void sendResetPasswordEmailAsync(String passwordResetLink, String email); void sendResetPasswordEmailAsync(String passwordResetLink, long ttlMs, String email);
void sendPasswordWasResetEmail(String loginLink, String email) throws ThingsboardException; void sendPasswordWasResetEmail(String loginLink, String email) throws ThingsboardException;

6
ui-ngx/src/app/core/http/user.service.ts

@ -16,7 +16,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils';
import { User, UserEmailInfo } from '@shared/models/user.model'; import { ActivationLinkInfo, User, UserEmailInfo } from '@shared/models/user.model';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { PageLink } from '@shared/models/page/page-link'; import { PageLink } from '@shared/models/page/page-link';
@ -77,6 +77,10 @@ export class UserService {
{...{responseType: 'text'}, ...defaultHttpOptionsFromConfig(config)}); {...{responseType: 'text'}, ...defaultHttpOptionsFromConfig(config)});
} }
public getActivationLinkInfo(userId: string, config?: RequestConfig): Observable<ActivationLinkInfo> {
return this.http.get<ActivationLinkInfo>(`/api/user/${userId}/activationLinkInfo`, defaultHttpOptionsFromConfig(config));
}
public sendActivationEmail(email: string, config?: RequestConfig) { public sendActivationEmail(email: string, config?: RequestConfig) {
const encodeEmail = encodeURIComponent(email); const encodeEmail = encodeURIComponent(email);
return this.http.post(`/api/user/sendActivationMail?email=${encodeEmail}`, null, defaultHttpOptionsFromConfig(config)); return this.http.post(`/api/user/sendActivationMail?email=${encodeEmail}`, null, defaultHttpOptionsFromConfig(config));

45
ui-ngx/src/app/core/services/dynamic-component-factory.service.ts

@ -22,7 +22,7 @@ import {
NgModule, NgModule,
NgModuleRef, NgModuleRef,
OnDestroy, OnDestroy,
Type, Type, ɵNG_COMP_DEF,
ɵresetCompiledComponents ɵresetCompiledComponents
} from '@angular/core'; } from '@angular/core';
import { from, Observable, of } from 'rxjs'; import { from, Observable, of } from 'rxjs';
@ -64,7 +64,6 @@ export class DynamicComponentFactoryService {
template: string, template: string,
modules?: Type<any>[], modules?: Type<any>[],
preserveWhitespaces?: boolean, preserveWhitespaces?: boolean,
compileAttempt = 1,
styles?: string[]): Observable<DynamicComponentData<T>> { styles?: string[]): Observable<DynamicComponentData<T>> {
return from(import('@angular/compiler')).pipe( return from(import('@angular/compiler')).pipe(
mergeMap(() => { mergeMap(() => {
@ -78,32 +77,26 @@ export class DynamicComponentFactoryService {
declarations: [comp], declarations: [comp],
imports: moduleImports imports: moduleImports
})(class DynamicComponentInstanceModule extends DynamicComponentModule {}); })(class DynamicComponentInstanceModule extends DynamicComponentModule {});
const module = this.compiler.compileModuleSync(dynamicComponentInstanceModule);
let moduleRef: NgModuleRef<any>;
try { try {
const module = this.compiler.compileModuleSync(dynamicComponentInstanceModule); moduleRef = module.create(this.injector);
let moduleRef: NgModuleRef<any>; // eslint-disable-next-line
try { comp[ɵNG_COMP_DEF];
moduleRef = module.create(this.injector); } catch (e) {
} catch (e) { this.compiler.clearCacheFor(module.moduleType);
this.compiler.clearCacheFor(module.moduleType); ɵresetCompiledComponents();
throw e; throw e;
}
this.dynamicComponentModulesMap.set(comp, {
moduleRef,
moduleType: module.moduleType
});
return of( {
componentType: comp,
componentModuleRef: moduleRef
});
} catch (error) {
if (compileAttempt === 1) {
ɵresetCompiledComponents();
return this.createDynamicComponent(componentType, template, modules, preserveWhitespaces, ++compileAttempt, styles);
} else {
console.error(error);
throw error;
}
} }
this.dynamicComponentModulesMap.set(comp, {
moduleRef,
moduleType: module.moduleType
});
return of( {
componentType: comp,
componentModuleRef: moduleRef
});
}) })
); );
} }

6
ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html

@ -126,6 +126,12 @@
<input matInput formControlName="borderRadius" placeholder="{{ 'widget-config.set' | translate }}"> <input matInput formControlName="borderRadius" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field> </mat-form-field>
</div> </div>
<div class="tb-form-row space-between">
<div>{{ 'widget-config.card-padding' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="padding" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div> </div>
<tb-widget-actions-panel <tb-widget-actions-panel
formControlName="actions"> formControlName="actions">

2
ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.ts

@ -144,6 +144,7 @@ export class ValueCardBasicConfigComponent extends BasicWidgetConfigComponent {
cardButtons: [this.getCardButtons(configData.config), []], cardButtons: [this.getCardButtons(configData.config), []],
borderRadius: [configData.config.borderRadius, []], borderRadius: [configData.config.borderRadius, []],
padding: [settings.padding, []],
actions: [configData.config.actions || {}, []] actions: [configData.config.actions || {}, []]
}); });
@ -183,6 +184,7 @@ export class ValueCardBasicConfigComponent extends BasicWidgetConfigComponent {
this.setCardButtons(config.cardButtons, this.widgetConfig.config); this.setCardButtons(config.cardButtons, this.widgetConfig.config);
this.widgetConfig.config.borderRadius = config.borderRadius; this.widgetConfig.config.borderRadius = config.borderRadius;
this.widgetConfig.config.settings.padding = config.padding;
this.widgetConfig.config.actions = config.actions; this.widgetConfig.config.actions = config.actions;
return this.widgetConfig; return this.widgetConfig;

6
ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.html

@ -94,6 +94,12 @@
<input matInput formControlName="borderRadius" placeholder="{{ 'widget-config.set' | translate }}"> <input matInput formControlName="borderRadius" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field> </mat-form-field>
</div> </div>
<div class="tb-form-row space-between">
<div>{{ 'widget-config.card-padding' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="padding" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div> </div>
<tb-widget-actions-panel <tb-widget-actions-panel
formControlName="actions"> formControlName="actions">

2
ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.ts

@ -79,6 +79,7 @@ export class StatusWidgetBasicConfigComponent extends BasicWidgetConfigComponent
cardButtons: [this.getCardButtons(configData.config), []], cardButtons: [this.getCardButtons(configData.config), []],
borderRadius: [configData.config.borderRadius, []], borderRadius: [configData.config.borderRadius, []],
padding: [settings.padding, []],
actions: [configData.config.actions || {}, []] actions: [configData.config.actions || {}, []]
}); });
@ -99,6 +100,7 @@ export class StatusWidgetBasicConfigComponent extends BasicWidgetConfigComponent
this.setCardButtons(config.cardButtons, this.widgetConfig.config); this.setCardButtons(config.cardButtons, this.widgetConfig.config);
this.widgetConfig.config.borderRadius = config.borderRadius; this.widgetConfig.config.borderRadius = config.borderRadius;
this.widgetConfig.config.settings.padding = config.padding;
this.widgetConfig.config.actions = config.actions; this.widgetConfig.config.actions = config.actions;
return this.widgetConfig; return this.widgetConfig;

6
ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.html

@ -211,6 +211,12 @@
<tb-background-settings formControlName="background"> <tb-background-settings formControlName="background">
</tb-background-settings> </tb-background-settings>
</div> </div>
<div class="tb-form-row space-between">
<div>{{ 'widget-config.card-padding' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="padding" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
<div class="tb-form-row space-between column-lt-md"> <div class="tb-form-row space-between column-lt-md">
<div translate>widget-config.show-card-buttons</div> <div translate>widget-config.show-card-buttons</div>
<mat-chip-listbox multiple formControlName="cardButtons"> <mat-chip-listbox multiple formControlName="cardButtons">

2
ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.ts

@ -108,6 +108,7 @@ export class SingleSwitchBasicConfigComponent extends BasicWidgetConfigComponent
cardButtons: [this.getCardButtons(configData.config), []], cardButtons: [this.getCardButtons(configData.config), []],
borderRadius: [configData.config.borderRadius, []], borderRadius: [configData.config.borderRadius, []],
padding: [settings.padding, []],
actions: [configData.config.actions || {}, []] actions: [configData.config.actions || {}, []]
}); });
@ -159,6 +160,7 @@ export class SingleSwitchBasicConfigComponent extends BasicWidgetConfigComponent
this.setCardButtons(config.cardButtons, this.widgetConfig.config); this.setCardButtons(config.cardButtons, this.widgetConfig.config);
this.widgetConfig.config.borderRadius = config.borderRadius; this.widgetConfig.config.borderRadius = config.borderRadius;
this.widgetConfig.config.settings.padding = config.padding;
this.widgetConfig.config.actions = config.actions; this.widgetConfig.config.actions = config.actions;
return this.widgetConfig; return this.widgetConfig;

6
ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.html

@ -208,6 +208,12 @@
<input matInput formControlName="borderRadius" placeholder="{{ 'widget-config.set' | translate }}"> <input matInput formControlName="borderRadius" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field> </mat-form-field>
</div> </div>
<div class="tb-form-row space-between">
<div>{{ 'widget-config.card-padding' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="padding" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div> </div>
<tb-widget-actions-panel <tb-widget-actions-panel
formControlName="actions"> formControlName="actions">

2
ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.ts

@ -147,6 +147,7 @@ export class WindSpeedDirectionBasicConfigComponent extends BasicWidgetConfigCom
cardButtons: [this.getCardButtons(configData.config), []], cardButtons: [this.getCardButtons(configData.config), []],
borderRadius: [configData.config.borderRadius, []], borderRadius: [configData.config.borderRadius, []],
padding: [settings.padding, []],
actions: [configData.config.actions || {}, []] actions: [configData.config.actions || {}, []]
}); });
@ -198,6 +199,7 @@ export class WindSpeedDirectionBasicConfigComponent extends BasicWidgetConfigCom
this.setCardButtons(config.cardButtons, this.widgetConfig.config); this.setCardButtons(config.cardButtons, this.widgetConfig.config);
this.widgetConfig.config.borderRadius = config.borderRadius; this.widgetConfig.config.borderRadius = config.borderRadius;
this.widgetConfig.config.settings.padding = config.padding;
this.widgetConfig.config.actions = config.actions; this.widgetConfig.config.actions = config.actions;
return this.widgetConfig; return this.widgetConfig;

54
ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html

@ -15,41 +15,41 @@
limitations under the License. limitations under the License.
--> -->
<div #valueCardPanel class="tb-value-card-panel" [class]="this.layout" [style]="backgroundStyle$ | async"> <div #valueCardPanel class="tb-value-card-panel" [class]="this.layout" [style.padding]="padding" [style]="backgroundStyle$ | async">
<div class="tb-value-card-overlay" [style]="overlayStyle"></div> <div class="tb-value-card-overlay" [style]="overlayStyle"></div>
<div class="tb-value-card-title-panel"> <div class="tb-value-card-title-panel">
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container> <ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container>
</div> </div>
<div #valueCardContent class="tb-value-card-content" [class]="this.layout"> <div #valueCardContent class="tb-value-card-content" [class]="this.layout">
<ng-container [ngSwitch]="layout"> <ng-container [ngSwitch]="layout">
<ng-template [ngSwitchCase]="valueCardLayout.square"> <ng-template [ngSwitchCase]="valueCardLayout.square">
<ng-container *ngTemplateOutlet="iconWithLabelTpl"></ng-container> <ng-container *ngTemplateOutlet="iconWithLabelTpl"></ng-container>
<ng-container *ngTemplateOutlet="valueTpl"></ng-container> <ng-container *ngTemplateOutlet="valueTpl"></ng-container>
</ng-template> </ng-template>
<ng-template [ngSwitchCase]="valueCardLayout.vertical"> <ng-template [ngSwitchCase]="valueCardLayout.vertical">
<ng-container *ngTemplateOutlet="labelTpl"></ng-container> <ng-container *ngTemplateOutlet="labelTpl"></ng-container>
<ng-container *ngTemplateOutlet="valueTpl"></ng-container> <ng-container *ngTemplateOutlet="valueTpl"></ng-container>
<ng-container *ngTemplateOutlet="iconTpl"></ng-container>
</ng-template>
<ng-template [ngSwitchCase]="valueCardLayout.centered">
<ng-container *ngTemplateOutlet="labelTpl"></ng-container>
<div class="tb-value-card-icon-row">
<ng-container *ngTemplateOutlet="iconTpl"></ng-container> <ng-container *ngTemplateOutlet="iconTpl"></ng-container>
</ng-template>
<ng-template [ngSwitchCase]="valueCardLayout.centered">
<ng-container *ngTemplateOutlet="labelTpl"></ng-container>
<div class="tb-value-card-icon-row">
<ng-container *ngTemplateOutlet="iconTpl"></ng-container>
<ng-container *ngTemplateOutlet="valueTpl"></ng-container>
</div>
<ng-container *ngTemplateOutlet="dateTpl"></ng-container>
</ng-template>
<ng-template [ngSwitchCase]="valueCardLayout.simplified">
<ng-container *ngTemplateOutlet="valueTpl"></ng-container> <ng-container *ngTemplateOutlet="valueTpl"></ng-container>
</div> <ng-container *ngTemplateOutlet="labelTpl"></ng-container>
<ng-container *ngTemplateOutlet="dateTpl"></ng-container> </ng-template>
</ng-template> <ng-template [ngSwitchCase]="layout === valueCardLayout.horizontal ||
<ng-template [ngSwitchCase]="valueCardLayout.simplified"> layout === valueCardLayout.horizontal_reversed ? layout : ''">
<ng-container *ngTemplateOutlet="valueTpl"></ng-container> <ng-container *ngTemplateOutlet="iconWithLabelTpl"></ng-container>
<ng-container *ngTemplateOutlet="labelTpl"></ng-container> <div fxFlex></div>
</ng-template> <ng-container *ngTemplateOutlet="valueTpl"></ng-container>
<ng-template [ngSwitchCase]="layout === valueCardLayout.horizontal || </ng-template>
layout === valueCardLayout.horizontal_reversed ? layout : ''"> </ng-container>
<ng-container *ngTemplateOutlet="iconWithLabelTpl"></ng-container>
<div fxFlex></div>
<ng-container *ngTemplateOutlet="valueTpl"></ng-container>
</ng-template>
</ng-container>
</div> </div>
</div> </div>
<ng-template #iconWithLabelTpl> <ng-template #iconWithLabelTpl>

13
ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts

@ -38,6 +38,7 @@ import {
getSingleTsValue, getSingleTsValue,
iconStyle, iconStyle,
overlayStyle, overlayStyle,
resolveCssSize,
textStyle textStyle
} from '@shared/models/widget-settings.models'; } from '@shared/models/widget-settings.models';
import { valueCardDefaultSettings, ValueCardLayout, ValueCardWidgetSettings } from './value-card-widget.models'; import { valueCardDefaultSettings, ValueCardLayout, ValueCardWidgetSettings } from './value-card-widget.models';
@ -48,7 +49,6 @@ import { ImagePipe } from '@shared/pipe/image.pipe';
import { DomSanitizer } from '@angular/platform-browser'; import { DomSanitizer } from '@angular/platform-browser';
const squareLayoutSize = 160; const squareLayoutSize = 160;
const squareLayoutPadding = 48;
const horizontalLayoutHeight = 80; const horizontalLayoutHeight = 80;
@Component({ @Component({
@ -96,6 +96,7 @@ export class ValueCardWidgetComponent implements OnInit, AfterViewInit, OnDestro
backgroundStyle$: Observable<ComponentStyle>; backgroundStyle$: Observable<ComponentStyle>;
overlayStyle: ComponentStyle = {}; overlayStyle: ComponentStyle = {};
padding: string;
private panelResize$: ResizeObserver; private panelResize$: ResizeObserver;
@ -148,6 +149,7 @@ export class ValueCardWidgetComponent implements OnInit, AfterViewInit, OnDestro
this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer);
this.overlayStyle = overlayStyle(this.settings.background.overlay); this.overlayStyle = overlayStyle(this.settings.background.overlay);
this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding;
} }
public ngAfterViewInit() { public ngAfterViewInit() {
@ -199,8 +201,13 @@ export class ValueCardWidgetComponent implements OnInit, AfterViewInit, OnDestro
} }
private onResize() { private onResize() {
const panelWidth = this.valueCardPanel.nativeElement.getBoundingClientRect().width - squareLayoutPadding; const computedStyle = getComputedStyle(this.valueCardPanel.nativeElement);
const panelHeight = this.valueCardPanel.nativeElement.getBoundingClientRect().height - (this.horizontal ? 0 : squareLayoutPadding); const [pLeft, pRight, pTop, pBottom] = ['paddingLeft', 'paddingRight', 'paddingTop', 'paddingBottom']
.map(side => resolveCssSize(computedStyle[side])[0]);
const widgetBoundingClientRect = this.valueCardPanel.nativeElement.getBoundingClientRect();
const panelWidth = widgetBoundingClientRect.width - (pLeft + pRight);
const panelHeight = widgetBoundingClientRect.height - (pTop + pBottom);
let scale: number; let scale: number;
if (!this.horizontal) { if (!this.horizontal) {
const size = Math.min(panelWidth, panelHeight); const size = Math.min(panelWidth, panelHeight);

10
ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.models.ts

@ -19,8 +19,10 @@ import {
BackgroundType, BackgroundType,
ColorSettings, ColorSettings,
constantColor, constantColor,
cssUnit, DateFormatSettings, cssUnit,
Font, lastUpdateAgoDateFormat DateFormatSettings,
Font,
lastUpdateAgoDateFormat
} from '@shared/models/widget-settings.models'; } from '@shared/models/widget-settings.models';
export enum ValueCardLayout { export enum ValueCardLayout {
@ -80,6 +82,7 @@ export interface ValueCardWidgetSettings {
dateFont: Font; dateFont: Font;
dateColor: ColorSettings; dateColor: ColorSettings;
background: BackgroundSettings; background: BackgroundSettings;
padding: string;
} }
export const valueCardDefaultSettings = (horizontal: boolean): ValueCardWidgetSettings => ({ export const valueCardDefaultSettings = (horizontal: boolean): ValueCardWidgetSettings => ({
@ -128,5 +131,6 @@ export const valueCardDefaultSettings = (horizontal: boolean): ValueCardWidgetSe
color: 'rgba(255,255,255,0.72)', color: 'rgba(255,255,255,0.72)',
blur: 3 blur: 3
} }
} },
padding: ''
}); });

2
ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.html

@ -20,7 +20,7 @@
<div class="tb-status-widget-title-panel"> <div class="tb-status-widget-title-panel">
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container> <ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container>
</div> </div>
<div #statusWidgetContent class="tb-status-widget-content" [class]="this.layout"> <div #statusWidgetContent class="tb-status-widget-content" [class]="this.layout" [style.padding]="padding">
<div class="tb-status-widget-icon-container"> <div class="tb-status-widget-icon-container">
<tb-icon [style]="iconStyle">{{ icon }}</tb-icon> <tb-icon [style]="iconStyle">{{ icon }}</tb-icon>
</div> </div>

25
ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.ts

@ -21,14 +21,16 @@ import {
ElementRef, ElementRef,
OnDestroy, OnDestroy,
OnInit, OnInit,
Renderer2, ViewChild, Renderer2,
ViewChild,
ViewEncapsulation ViewEncapsulation
} from '@angular/core'; } from '@angular/core';
import { BasicActionWidgetComponent } from '@home/components/widget/lib/action/action-widget.models'; import { BasicActionWidgetComponent } from '@home/components/widget/lib/action/action-widget.models';
import { import {
statusWidgetDefaultSettings, statusWidgetDefaultSettings,
StatusWidgetLayout, StatusWidgetLayout,
StatusWidgetSettings, StatusWidgetStateSettings StatusWidgetSettings,
StatusWidgetStateSettings
} from '@home/components/widget/lib/indicator/status-widget.models'; } from '@home/components/widget/lib/indicator/status-widget.models';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { import {
@ -36,12 +38,12 @@ import {
ComponentStyle, ComponentStyle,
iconStyle, iconStyle,
overlayStyle, overlayStyle,
resolveCssSize,
textStyle textStyle
} from '@shared/models/widget-settings.models'; } from '@shared/models/widget-settings.models';
import { ResizeObserver } from '@juggle/resize-observer'; import { ResizeObserver } from '@juggle/resize-observer';
import { ImagePipe } from '@shared/pipe/image.pipe'; import { ImagePipe } from '@shared/pipe/image.pipe';
import { DomSanitizer } from '@angular/platform-browser'; import { DomSanitizer } from '@angular/platform-browser';
import { UtilsService } from '@core/services/utils.service';
import { ValueType } from '@shared/models/constants'; import { ValueType } from '@shared/models/constants';
const initialStatusWidgetSize = 147; const initialStatusWidgetSize = 147;
@ -65,6 +67,7 @@ export class StatusWidgetComponent extends
backgroundStyle$: Observable<ComponentStyle>; backgroundStyle$: Observable<ComponentStyle>;
overlayStyle: ComponentStyle = {}; overlayStyle: ComponentStyle = {};
padding: string;
overlayInset = '12px'; overlayInset = '12px';
borderRadius = ''; borderRadius = '';
@ -101,9 +104,7 @@ export class StatusWidgetComponent extends
constructor(protected imagePipe: ImagePipe, constructor(protected imagePipe: ImagePipe,
protected sanitizer: DomSanitizer, protected sanitizer: DomSanitizer,
private renderer: Renderer2, private renderer: Renderer2,
private utils: UtilsService, protected cd: ChangeDetectorRef) {
protected cd: ChangeDetectorRef,
private elementRef: ElementRef) {
super(cd); super(cd);
} }
@ -191,8 +192,13 @@ export class StatusWidgetComponent extends
} }
private onResize() { private onResize() {
const panelWidth = this.statusWidgetPanel.nativeElement.getBoundingClientRect().width; const computedStyle = getComputedStyle(this.statusWidgetPanel.nativeElement);
const panelHeight = this.statusWidgetPanel.nativeElement.getBoundingClientRect().height; const [pLeft, pRight, pTop, pBottom] = ['paddingLeft', 'paddingRight', 'paddingTop', 'paddingBottom']
.map(side => resolveCssSize(computedStyle[side])[0]);
const widgetBoundingClientRect = this.statusWidgetPanel.nativeElement.getBoundingClientRect();
const panelWidth = widgetBoundingClientRect.width - (pLeft + pRight);
const panelHeight = widgetBoundingClientRect.height - (pTop + pBottom);
const targetSize = Math.min(panelWidth, panelHeight); const targetSize = Math.min(panelWidth, panelHeight);
const scale = targetSize / initialStatusWidgetSize; const scale = targetSize / initialStatusWidgetSize;
const width = initialStatusWidgetSize; const width = initialStatusWidgetSize;
@ -220,6 +226,9 @@ export class StatusWidgetComponent extends
this.showLabel = stateSettings.showLabel && this.layout !== StatusWidgetLayout.icon; this.showLabel = stateSettings.showLabel && this.layout !== StatusWidgetLayout.icon;
this.showStatus = stateSettings.showStatus && this.layout !== StatusWidgetLayout.icon; this.showStatus = stateSettings.showStatus && this.layout !== StatusWidgetLayout.icon;
this.icon = stateSettings.icon; this.icon = stateSettings.icon;
this.padding = stateSettings.backgroundDisabled.overlay.enabled || stateSettings.background.overlay.enabled
? undefined
: this.settings.padding;
const primaryColor = disabled ? stateSettings.primaryColorDisabled : stateSettings.primaryColor; const primaryColor = disabled ? stateSettings.primaryColorDisabled : stateSettings.primaryColor;
const secondaryColor = disabled ? stateSettings.secondaryColorDisabled : stateSettings.secondaryColor; const secondaryColor = disabled ? stateSettings.secondaryColorDisabled : stateSettings.secondaryColor;

4
ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.models.ts

@ -65,6 +65,7 @@ export interface StatusWidgetSettings {
layout: StatusWidgetLayout; layout: StatusWidgetLayout;
onState: StatusWidgetStateSettings; onState: StatusWidgetStateSettings;
offState: StatusWidgetStateSettings; offState: StatusWidgetStateSettings;
padding: string
} }
export const statusWidgetDefaultSettings: StatusWidgetSettings = { export const statusWidgetDefaultSettings: StatusWidgetSettings = {
@ -200,5 +201,6 @@ export const statusWidgetDefaultSettings: StatusWidgetSettings = {
blur: 3 blur: 3
} }
} }
} },
padding: ''
}; };

2
ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html

@ -15,7 +15,7 @@
limitations under the License. limitations under the License.
--> -->
<div #singleSwitchPanel class="tb-single-switch-panel" [class.auto-scale]="autoScale" [style.pointer-events]="ctx.isEdit ? 'none' : 'all'" [style]="backgroundStyle$ | async"> <div #singleSwitchPanel class="tb-single-switch-panel" [class.auto-scale]="autoScale" [style.pointer-events]="ctx.isEdit ? 'none' : 'all'" [style.padding]="padding" [style]="backgroundStyle$ | async">
<div class="tb-single-switch-overlay" [style]="overlayStyle" [style.inset]="overlayInset"></div> <div class="tb-single-switch-overlay" [style]="overlayStyle" [style.inset]="overlayInset"></div>
<div class="tb-single-switch-title-panel"> <div class="tb-single-switch-title-panel">
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container> <ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container>

13
ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts

@ -36,6 +36,7 @@ import {
ComponentStyle, ComponentStyle,
iconStyle, iconStyle,
overlayStyle, overlayStyle,
resolveCssSize,
textStyle textStyle
} from '@shared/models/widget-settings.models'; } from '@shared/models/widget-settings.models';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@ -74,6 +75,7 @@ export class SingleSwitchWidgetComponent extends
backgroundStyle$: Observable<ComponentStyle>; backgroundStyle$: Observable<ComponentStyle>;
overlayStyle: ComponentStyle = {}; overlayStyle: ComponentStyle = {};
padding: string;
overlayInset = '12px'; overlayInset = '12px';
value = false; value = false;
@ -123,6 +125,7 @@ export class SingleSwitchWidgetComponent extends
this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer);
this.overlayStyle = overlayStyle(this.settings.background.overlay); this.overlayStyle = overlayStyle(this.settings.background.overlay);
this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding;
this.layout = this.settings.layout; this.layout = this.settings.layout;
@ -231,11 +234,15 @@ export class SingleSwitchWidgetComponent extends
} }
private onResize() { private onResize() {
const height = this.singleSwitchPanel.nativeElement.getBoundingClientRect().height; const widgetBoundingClientRect = this.singleSwitchPanel.nativeElement.getBoundingClientRect();
const height = widgetBoundingClientRect.height;
const switchScale = height / initialSwitchHeight; const switchScale = height / initialSwitchHeight;
const paddingScale = Math.min(switchScale, 1); const paddingScale = Math.min(switchScale, 1);
const panelWidth = this.singleSwitchPanel.nativeElement.getBoundingClientRect().width - (horizontalLayoutPadding * paddingScale); const computedStyle = getComputedStyle(this.singleSwitchPanel.nativeElement);
const panelHeight = this.singleSwitchPanel.nativeElement.getBoundingClientRect().height - (verticalLayoutPadding * paddingScale); const [pLeft, pRight, pTop, pBottom] = ['paddingLeft', 'paddingRight', 'paddingTop', 'paddingBottom']
.map(side => resolveCssSize(computedStyle[side])[0]);
const panelWidth = widgetBoundingClientRect.width - ((pLeft + pRight) || horizontalLayoutPadding * paddingScale);
const panelHeight = widgetBoundingClientRect.height - ((pTop + pBottom) || verticalLayoutPadding * paddingScale);
this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'transform', `scale(1)`); this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'transform', `scale(1)`);
this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'width', 'auto'); this.renderer.setStyle(this.singleSwitchContent.nativeElement, 'width', 'auto');
let contentWidth = this.singleSwitchToggleRow.nativeElement.getBoundingClientRect().width; let contentWidth = this.singleSwitchToggleRow.nativeElement.getBoundingClientRect().width;

4
ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.models.ts

@ -80,6 +80,7 @@ export interface SingleSwitchWidgetSettings {
offLabelFont: Font; offLabelFont: Font;
offLabelColor: string; offLabelColor: string;
background: BackgroundSettings; background: BackgroundSettings;
padding: string;
} }
export const singleSwitchDefaultSettings: SingleSwitchWidgetSettings = { export const singleSwitchDefaultSettings: SingleSwitchWidgetSettings = {
@ -217,5 +218,6 @@ export const singleSwitchDefaultSettings: SingleSwitchWidgetSettings = {
color: 'rgba(255,255,255,0.72)', color: 'rgba(255,255,255,0.72)',
blur: 3 blur: 3
} }
} },
padding: ''
}; };

18
ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts

@ -191,10 +191,10 @@ export interface ScadaSymbolMetadata {
properties: ScadaSymbolProperty[]; properties: ScadaSymbolProperty[];
} }
export const emptyMetadata = (): ScadaSymbolMetadata => ({ export const emptyMetadata = (width?: number, height?: number): ScadaSymbolMetadata => ({
title: '', title: '',
widgetSizeX: 3, widgetSizeX: width ? width/100 : 3,
widgetSizeY: 3, widgetSizeY: height ? height/100 : 3,
tags: [], tags: [],
behavior: [], behavior: [],
properties: [] properties: []
@ -255,7 +255,17 @@ const parseScadaSymbolMetadataFromDom = (svgDoc: Document): ScadaSymbolMetadata
if (elements.length) { if (elements.length) {
return JSON.parse(elements[0].textContent); return JSON.parse(elements[0].textContent);
} else { } else {
return emptyMetadata(); const svg = svgDoc.getElementsByTagName('svg')[0];
let width = null;
let height = null;
if (svg.viewBox.baseVal.width && svg.viewBox.baseVal.height) {
width = svg.viewBox.baseVal.width;
height = svg.viewBox.baseVal.height;
} else if (svg.width.baseVal.value && svg.height.baseVal.value) {
width = svg.width.baseVal.value;
height = svg.height.baseVal.value;
}
return emptyMetadata(width, height);
} }
} catch (_e) { } catch (_e) {
console.error(_e); console.error(_e);

6
ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.html

@ -93,5 +93,11 @@
<tb-background-settings formControlName="background"> <tb-background-settings formControlName="background">
</tb-background-settings> </tb-background-settings>
</div> </div>
<div class="tb-form-row space-between">
<div>{{ 'widget-config.card-padding' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="padding" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div> </div>
</ng-container> </ng-container>

3
ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.ts

@ -110,7 +110,8 @@ export class ValueCardWidgetSettingsComponent extends WidgetSettingsComponent {
dateFont: [settings.dateFont, []], dateFont: [settings.dateFont, []],
dateColor: [settings.dateColor, []], dateColor: [settings.dateColor, []],
background: [settings.background, []] background: [settings.background, []],
padding: [settings.padding, []]
}); });
} }

6
ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html

@ -123,6 +123,12 @@
<tb-background-settings formControlName="background"> <tb-background-settings formControlName="background">
</tb-background-settings> </tb-background-settings>
</div> </div>
<div class="tb-form-row space-between">
<div>{{ 'widget-config.card-padding' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="padding" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div> </div>
<div class="tb-form-panel"> <div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.single-switch.switch</div> <div class="tb-form-panel-title" translate>widgets.single-switch.switch</div>

3
ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.ts

@ -102,7 +102,8 @@ export class SingleSwitchWidgetSettingsComponent extends WidgetSettingsComponent
offLabelFont: [settings.offLabelFont, []], offLabelFont: [settings.offLabelFont, []],
offLabelColor: [settings.offLabelColor, []], offLabelColor: [settings.offLabelColor, []],
background: [settings.background, []] background: [settings.background, []],
padding: [settings.padding, []]
}); });
} }

6
ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.html

@ -78,5 +78,11 @@
[layout]="statusWidgetSettingsForm.get('layout').value" [layout]="statusWidgetSettingsForm.get('layout').value"
formControlName="offState"> formControlName="offState">
</tb-status-widget-state-settings> </tb-status-widget-state-settings>
<div class="tb-form-row space-between">
<div>{{ 'widget-config.card-padding' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="padding" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div> </div>
</ng-container> </ng-container>

3
ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.ts

@ -73,7 +73,8 @@ export class StatusWidgetSettingsComponent extends WidgetSettingsComponent {
disabledState: [settings.disabledState, []], disabledState: [settings.disabledState, []],
layout: [settings.layout, []], layout: [settings.layout, []],
onState: [settings.onState, []], onState: [settings.onState, []],
offState: [settings.offState, []] offState: [settings.offState, []],
padding: [settings.padding, []]
}); });
} }
} }

6
ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.html

@ -100,5 +100,11 @@
<tb-background-settings formControlName="background"> <tb-background-settings formControlName="background">
</tb-background-settings> </tb-background-settings>
</div> </div>
<div class="tb-form-row space-between">
<div>{{ 'widget-config.card-padding' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="padding" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div> </div>
</ng-container> </ng-container>

3
ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.ts

@ -91,7 +91,8 @@ export class WindSpeedDirectionWidgetSettingsComponent extends WidgetSettingsCom
arrowColor: [settings.arrowColor, []], arrowColor: [settings.arrowColor, []],
background: [settings.background, []] background: [settings.background, []],
padding: [settings.padding, []]
}); });
} }

2
ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.html

@ -15,7 +15,7 @@
limitations under the License. limitations under the License.
--> -->
<div class="tb-wind-speed-direction-panel" [style]="backgroundStyle$ | async" [class.tb-wind-speed-direction-pointer]="hasCardClickAction" (click)="cardClick($event)"> <div class="tb-wind-speed-direction-panel" [style.padding]="padding" [style]="backgroundStyle$ | async" [class.tb-wind-speed-direction-pointer]="hasCardClickAction" (click)="cardClick($event)">
<div class="tb-wind-speed-direction-overlay" [style]="overlayStyle"></div> <div class="tb-wind-speed-direction-overlay" [style]="overlayStyle"></div>
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container> <ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container>
<div class="tb-wind-speed-direction-content"> <div class="tb-wind-speed-direction-content">

6
ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.ts

@ -42,7 +42,6 @@ import {
getSingleTsValueByDataKey, getSingleTsValueByDataKey,
overlayStyle overlayStyle
} from '@shared/models/widget-settings.models'; } from '@shared/models/widget-settings.models';
import { WidgetComponent } from '@home/components/widget/widget.component';
import { formatValue, isDefinedAndNotNull, isNumeric } from '@core/utils'; import { formatValue, isDefinedAndNotNull, isNumeric } from '@core/utils';
import { ResizeObserver } from '@juggle/resize-observer'; import { ResizeObserver } from '@juggle/resize-observer';
import { Path, Svg, SVG, Text } from '@svgdotjs/svg.js'; import { Path, Svg, SVG, Text } from '@svgdotjs/svg.js';
@ -92,6 +91,7 @@ export class WindSpeedDirectionWidgetComponent implements OnInit, OnDestroy, Aft
backgroundStyle$: Observable<ComponentStyle>; backgroundStyle$: Observable<ComponentStyle>;
overlayStyle: ComponentStyle = {}; overlayStyle: ComponentStyle = {};
padding: string;
shapeResize$: ResizeObserver; shapeResize$: ResizeObserver;
@ -111,8 +111,7 @@ export class WindSpeedDirectionWidgetComponent implements OnInit, OnDestroy, Aft
private windDirection = 0; private windDirection = 0;
private centerValueText = 'N/A'; private centerValueText = 'N/A';
constructor(private widgetComponent: WidgetComponent, constructor(private imagePipe: ImagePipe,
private imagePipe: ImagePipe,
private sanitizer: DomSanitizer, private sanitizer: DomSanitizer,
private renderer: Renderer2, private renderer: Renderer2,
private cd: ChangeDetectorRef) { private cd: ChangeDetectorRef) {
@ -142,6 +141,7 @@ export class WindSpeedDirectionWidgetComponent implements OnInit, OnDestroy, Aft
this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer);
this.overlayStyle = overlayStyle(this.settings.background.overlay); this.overlayStyle = overlayStyle(this.settings.background.overlay);
this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding;
this.hasCardClickAction = this.ctx.actionsApi.getActionDescriptors('cardClick').length > 0; this.hasCardClickAction = this.ctx.actionsApi.getActionDescriptors('cardClick').length > 0;
} }

5
ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.models.ts

@ -14,7 +14,6 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { BatteryLevelLayout } from '@home/components/widget/lib/indicator/battery-level-widget.models';
import { import {
BackgroundSettings, BackgroundSettings,
BackgroundType, BackgroundType,
@ -59,6 +58,7 @@ export interface WindSpeedDirectionWidgetSettings {
minorTicksColor: string; minorTicksColor: string;
minorTicksFont: Font; minorTicksFont: Font;
background: BackgroundSettings; background: BackgroundSettings;
padding: string
} }
export const windSpeedDirectionDefaultSettings: WindSpeedDirectionWidgetSettings = { export const windSpeedDirectionDefaultSettings: WindSpeedDirectionWidgetSettings = {
@ -101,5 +101,6 @@ export const windSpeedDirectionDefaultSettings: WindSpeedDirectionWidgetSettings
color: 'rgba(255,255,255,0.72)', color: 'rgba(255,255,255,0.72)',
blur: 3 blur: 3
} }
} },
padding: ''
}; };

38
ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html

@ -46,6 +46,44 @@
<input matInput type="email" <input matInput type="email"
formControlName="userLockoutNotificationEmail"/> formControlName="userLockoutNotificationEmail"/>
</mat-form-field> </mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>admin.user-activation-token-ttl</mat-label>
<input matInput type="number"
formControlName="userActivationTokenTtl"
step="1"
min="1"
max="24"/>
<mat-error *ngIf="securitySettingsFormGroup.get('userActivationTokenTtl').hasError('min')">
{{ 'admin.user-activation-token-ttl-range' | translate }}
</mat-error>
<mat-error *ngIf="securitySettingsFormGroup.get('userActivationTokenTtl').hasError('max')">
{{ 'admin.user-activation-token-ttl-range' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>admin.password-reset-token-ttl</mat-label>
<input matInput type="number"
formControlName="passwordResetTokenTtl"
step="1"
min="1"
max="24"/>
<mat-error *ngIf="securitySettingsFormGroup.get('passwordResetTokenTtl').hasError('min')">
{{ 'admin.password-reset-token-ttl-range' | translate }}
</mat-error>
<mat-error *ngIf="securitySettingsFormGroup.get('passwordResetTokenTtl').hasError('max')">
{{ 'admin.password-reset-token-ttl-range' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>admin.mobile-secret-key-length</mat-label>
<input matInput type="number"
formControlName="mobileSecretKeyLength"
step="1"
min="1"/>
<mat-error *ngIf="securitySettingsFormGroup.get('mobileSecretKeyLength').hasError('min')">
{{ 'admin.mobile-secret-key-length-range' | translate }}
</mat-error>
</mat-form-field>
</fieldset> </fieldset>
<fieldset class="fields-group"> <fieldset class="fields-group">

3
ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts

@ -75,6 +75,9 @@ export class SecuritySettingsComponent extends PageComponent implements HasConfi
this.securitySettingsFormGroup = this.fb.group({ this.securitySettingsFormGroup = this.fb.group({
maxFailedLoginAttempts: [null, [Validators.min(0)]], maxFailedLoginAttempts: [null, [Validators.min(0)]],
userLockoutNotificationEmail: ['', []], userLockoutNotificationEmail: ['', []],
userActivationTokenTtl: [24, [Validators.required, Validators.min(1), Validators.max(24)]],
passwordResetTokenTtl: [24, [Validators.required, Validators.min(1), Validators.max(24)]],
mobileSecretKeyLength: [null, [Validators.min(1)]],
passwordPolicy: this.fb.group( passwordPolicy: this.fb.group(
{ {
minimumLength: [null, [Validators.required, Validators.min(6), Validators.max(50)]], minimumLength: [null, [Validators.required, Validators.min(6), Validators.max(50)]],

1
ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.html

@ -136,6 +136,7 @@
<div class="fixed-title-width" translate>scada.property.disable-on-property</div> <div class="fixed-title-width" translate>scada.property.disable-on-property</div>
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic"> <mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="disableOnProperty" placeholder="{{ 'widget-config.set' | translate }}"> <mat-select formControlName="disableOnProperty" placeholder="{{ 'widget-config.set' | translate }}">
<mat-option [value]="null"></mat-option>
<mat-option *ngFor="let prop of booleanPropertyIds" [value]="prop"> <mat-option *ngFor="let prop of booleanPropertyIds" [value]="prop">
{{ prop }} {{ prop }}
</mat-option> </mat-option>

2
ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html

@ -30,7 +30,7 @@
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div> <div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content tb-toast toastTarget="activationLinkDialogContent"> <div mat-dialog-content tb-toast toastTarget="activationLinkDialogContent">
<div class="mat-content" fxLayout="column"> <div class="mat-content" fxLayout="column">
<span [innerHTML]="'user.activation-link-text' | translate: {activationLink: activationLink}"></span> <span [innerHTML]="'user.activation-link-text' | translate: {activationLink: activationLink, activationLinkTtl: activationLinkTtl}"></span>
<div fxLayout="row" fxLayoutAlign="start center"> <div fxLayout="row" fxLayoutAlign="start center">
<pre class="tb-highlight" fxFlex><code>{{ activationLink }}</code></pre> <pre class="tb-highlight" fxFlex><code>{{ activationLink }}</code></pre>
<button mat-icon-button <button mat-icon-button

18
ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.ts

@ -14,7 +14,7 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { Component, Inject, OnInit } from '@angular/core'; import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
@ -22,29 +22,31 @@ import { TranslateService } from '@ngx-translate/core';
import { ActionNotificationShow } from '@core/notification/notification.actions'; import { ActionNotificationShow } from '@core/notification/notification.actions';
import { DialogComponent } from '@shared/components/dialog.component'; import { DialogComponent } from '@shared/components/dialog.component';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { ActivationLinkInfo } from '@shared/models/user.model';
import { MillisecondsToTimeStringPipe } from '@shared/pipe/milliseconds-to-time-string.pipe';
export interface ActivationLinkDialogData { export interface ActivationLinkDialogData {
activationLink: string; activationLinkInfo: ActivationLinkInfo;
} }
@Component({ @Component({
selector: 'tb-activation-link-dialog', selector: 'tb-activation-link-dialog',
templateUrl: './activation-link-dialog.component.html' templateUrl: './activation-link-dialog.component.html'
}) })
export class ActivationLinkDialogComponent extends DialogComponent<ActivationLinkDialogComponent, void> implements OnInit { export class ActivationLinkDialogComponent extends DialogComponent<ActivationLinkDialogComponent, void> {
activationLink: string; activationLink: string;
activationLinkTtl: string;
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
protected router: Router, protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: ActivationLinkDialogData, @Inject(MAT_DIALOG_DATA) public data: ActivationLinkDialogData,
public dialogRef: MatDialogRef<ActivationLinkDialogComponent, void>, public dialogRef: MatDialogRef<ActivationLinkDialogComponent, void>,
private translate: TranslateService) { private translate: TranslateService,
private millisecondsToTimeStringPipe: MillisecondsToTimeStringPipe) {
super(store, router, dialogRef); super(store, router, dialogRef);
this.activationLink = this.data.activationLink; this.activationLink = this.data.activationLinkInfo.value;
} this.activationLinkTtl = this.millisecondsToTimeStringPipe.transform(this.data.activationLinkInfo.ttlMs);
ngOnInit(): void {
} }
close(): void { close(): void {

8
ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts

@ -21,7 +21,7 @@ import { AppState } from '@core/core.state';
import { UntypedFormGroup } from '@angular/forms'; import { UntypedFormGroup } from '@angular/forms';
import { UserComponent } from '@modules/home/pages/user/user.component'; import { UserComponent } from '@modules/home/pages/user/user.component';
import { Authority } from '@shared/models/authority.enum'; import { Authority } from '@shared/models/authority.enum';
import { ActivationMethod, activationMethodTranslations, User } from '@shared/models/user.model'; import { ActivationLinkInfo, ActivationMethod, activationMethodTranslations, User } from '@shared/models/user.model';
import { CustomerId } from '@shared/models/id/customer-id'; import { CustomerId } from '@shared/models/id/customer-id';
import { UserService } from '@core/http/user.service'; import { UserService } from '@core/http/user.service';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@ -88,7 +88,7 @@ export class AddUserDialogComponent extends DialogComponent<AddUserDialogCompone
this.userService.saveUser(this.user, sendActivationEmail).subscribe( this.userService.saveUser(this.user, sendActivationEmail).subscribe(
(user) => { (user) => {
if (this.activationMethod === ActivationMethod.DISPLAY_ACTIVATION_LINK) { if (this.activationMethod === ActivationMethod.DISPLAY_ACTIVATION_LINK) {
this.userService.getActivationLink(user.id.id).subscribe( this.userService.getActivationLinkInfo(user.id.id).subscribe(
(activationLink) => { (activationLink) => {
this.displayActivationLink(activationLink).subscribe( this.displayActivationLink(activationLink).subscribe(
() => { () => {
@ -105,13 +105,13 @@ export class AddUserDialogComponent extends DialogComponent<AddUserDialogCompone
} }
} }
displayActivationLink(activationLink: string): Observable<void> { displayActivationLink(activationLinkInfo: ActivationLinkInfo): Observable<void> {
return this.dialog.open<ActivationLinkDialogComponent, ActivationLinkDialogData, return this.dialog.open<ActivationLinkDialogComponent, ActivationLinkDialogData,
void>(ActivationLinkDialogComponent, { void>(ActivationLinkDialogComponent, {
disableClose: true, disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: { data: {
activationLink activationLinkInfo
} }
}).afterClosed(); }).afterClosed();
} }

6
ui-ngx/src/app/modules/home/pages/user/users-table-config.resolver.ts

@ -193,14 +193,14 @@ export class UsersTableConfigResolver implements Resolve<EntityTableConfig<User>
if ($event) { if ($event) {
$event.stopPropagation(); $event.stopPropagation();
} }
this.userService.getActivationLink(user.id.id).subscribe( this.userService.getActivationLinkInfo(user.id.id).subscribe(
(activationLink) => { (activationLinkInfo) => {
this.dialog.open<ActivationLinkDialogComponent, ActivationLinkDialogData, this.dialog.open<ActivationLinkDialogComponent, ActivationLinkDialogData,
void>(ActivationLinkDialogComponent, { void>(ActivationLinkDialogComponent, {
disableClose: true, disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: { data: {
activationLink activationLinkInfo
} }
}); });
} }

1
ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html

@ -39,6 +39,7 @@
</mat-form-field> </mat-form-field>
<tb-widgets-bundle-select <tb-widgets-bundle-select
formControlName="widgetsBundle" formControlName="widgetsBundle"
createNew
bundlesScope="{{bundlesScope}}"> bundlesScope="{{bundlesScope}}">
</tb-widgets-bundle-select> </tb-widgets-bundle-select>
</div> </div>

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

@ -28,6 +28,7 @@ import { WidgetTypeComponent } from '@home/pages/widget/widget-type.component';
import { WidgetTypeTabsComponent } from '@home/pages/widget/widget-type-tabs.component'; import { WidgetTypeTabsComponent } from '@home/pages/widget/widget-type-tabs.component';
import { WidgetsBundleWidgetsComponent } from '@home/pages/widget/widgets-bundle-widgets.component'; import { WidgetsBundleWidgetsComponent } from '@home/pages/widget/widgets-bundle-widgets.component';
import { WidgetTypeAutocompleteComponent } from '@home/pages/widget/widget-type-autocomplete.component'; import { WidgetTypeAutocompleteComponent } from '@home/pages/widget/widget-type-autocomplete.component';
import { WidgetsBundleDialogComponent } from '@home/pages/widget/widgets-bundle-dialog.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -39,7 +40,8 @@ import { WidgetTypeAutocompleteComponent } from '@home/pages/widget/widget-type-
SelectWidgetTypeDialogComponent, SelectWidgetTypeDialogComponent,
SaveWidgetTypeAsDialogComponent, SaveWidgetTypeAsDialogComponent,
WidgetTypeTabsComponent, WidgetTypeTabsComponent,
WidgetsBundleTabsComponent WidgetsBundleTabsComponent,
WidgetsBundleDialogComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,

53
ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.html

@ -0,0 +1,53 @@
<!--
Copyright © 2016-2024 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 (ngSubmit)="save()" style="width: 650px;">
<mat-toolbar color="primary">
<h2 translate>widgets-bundle.add</h2>
<span fxFlex></span>
<button mat-icon-button
(click)="cancel()"
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>
<tb-widgets-bundle
#widgetsBundleComponent
[isEdit]="true"
[entity]="widgetsBundle"
[standalone]="true">
</tb-widgets-bundle>
</div>
<div mat-dialog-actions fxLayoutAlign="end center">
<button mat-button color="primary"
type="button"
cdkFocusInitial
[disabled]="(isLoading$ | async)"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button color="primary"
type="submit"
[disabled]="(isLoading$ | async) || widgetsBundleComponent.entityForm?.invalid || !widgetsBundleComponent.entityForm?.dirty">
{{ 'action.add' | translate }}
</button>
</div>
</form>

22
ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.scss

@ -0,0 +1,22 @@
/**
* Copyright © 2016-2024 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 ::ng-deep {
tb-widgets-bundle {
.mat-padding {
padding: 0;
}
}
}

78
ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.ts

@ -0,0 +1,78 @@
///
/// Copyright © 2016-2024 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, Inject, SkipSelf, ViewChild } from '@angular/core';
import { ErrorStateMatcher } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { DialogComponent } from '@shared/components/dialog.component';
import { Router } from '@angular/router';
import { WidgetsBundle } from '@shared/models/widgets-bundle.model';
import { WidgetsBundleComponent } from '@home/pages/widget/widgets-bundle.component';
import { WidgetService } from '@core/http/widget.service';
export interface WidgetsBundleDialogData {
widgetsBundle: WidgetsBundle;
}
@Component({
selector: 'tb-widgets-bundle-dialog',
templateUrl: './widgets-bundle-dialog.component.html',
providers: [{provide: ErrorStateMatcher, useExisting: WidgetsBundleDialogComponent}],
styleUrls: ['widgets-bundle-dialog.component.scss']
})
export class WidgetsBundleDialogComponent extends
DialogComponent<WidgetsBundleDialogComponent, WidgetsBundle> implements ErrorStateMatcher {
widgetsBundle: WidgetsBundle;
submitted = false;
@ViewChild('widgetsBundleComponent', {static: true}) widgetsBundleComponent: WidgetsBundleComponent;
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: WidgetsBundleDialogData,
public dialogRef: MatDialogRef<WidgetsBundleDialogComponent, WidgetsBundle>,
@SkipSelf() private errorStateMatcher: ErrorStateMatcher,
private widgetsService: WidgetService) {
super(store, router, dialogRef);
this.widgetsBundle = this.data.widgetsBundle;
}
isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
const customErrorState = !!(control && control.invalid && this.submitted);
return originalErrorState || customErrorState;
}
cancel(): void {
this.dialogRef.close(null);
}
save(): void {
this.submitted = true;
if (this.widgetsBundleComponent.entityForm.valid) {
this.widgetsBundle = {...this.widgetsBundle, ...this.widgetsBundleComponent.entityFormValue()};
this.widgetsService.saveWidgetsBundle(this.widgetsBundle).subscribe((widgetBundle) => {
this.dialogRef.close(widgetBundle);
});
}
}
}

2
ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html

@ -15,7 +15,7 @@
limitations under the License. limitations under the License.
--> -->
<div class="tb-details-buttons" fxLayout.xs="column"> <div class="tb-details-buttons" fxLayout.xs="column" *ngIf="!standalone">
<button mat-raised-button color="primary" <button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)" [disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'open')" (click)="onEntityAction($event, 'open')"

9
ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.ts

@ -14,7 +14,7 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { ChangeDetectorRef, Component, Inject } from '@angular/core'; import { ChangeDetectorRef, Component, Inject, Input, Optional } from '@angular/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { EntityComponent } from '../../components/entity/entity.component'; import { EntityComponent } from '../../components/entity/entity.component';
@ -29,9 +29,12 @@ import { EntityTableConfig } from '@home/models/entity/entities-table-config.mod
}) })
export class WidgetsBundleComponent extends EntityComponent<WidgetsBundle> { export class WidgetsBundleComponent extends EntityComponent<WidgetsBundle> {
@Input()
standalone = false;
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
@Inject('entity') protected entityValue: WidgetsBundle, @Optional() @Inject('entity') protected entityValue: WidgetsBundle,
@Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig<WidgetsBundle>, @Optional() @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig<WidgetsBundle>,
public fb: UntypedFormBuilder, public fb: UntypedFormBuilder,
protected cd: ChangeDetectorRef) { protected cd: ChangeDetectorRef) {
super(store, fb, entityValue, entitiesTableConfigValue, cd); super(store, fb, entityValue, entitiesTableConfigValue, cd);

20
ui-ngx/src/app/modules/login/login-routing.module.ts

@ -24,6 +24,7 @@ import { ResetPasswordComponent } from '@modules/login/pages/login/reset-passwor
import { CreatePasswordComponent } from '@modules/login/pages/login/create-password.component'; import { CreatePasswordComponent } from '@modules/login/pages/login/create-password.component';
import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-factor-auth-login.component'; import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-factor-auth-login.component';
import { Authority } from '@shared/models/authority.enum'; import { Authority } from '@shared/models/authority.enum';
import { LinkExpiredComponent } from '@modules/login/pages/login/link-expired.component';
const routes: Routes = [ const routes: Routes = [
{ {
@ -81,6 +82,25 @@ const routes: Routes = [
module: 'public' module: 'public'
}, },
canActivate: [AuthGuard] canActivate: [AuthGuard]
},
{
path: 'activationLinkExpired',
component: LinkExpiredComponent,
data: {
title: 'login.activation-link-expired',
module: 'public'
},
canActivate: [AuthGuard]
},
{
path: 'passwordResetLinkExpired',
component: LinkExpiredComponent,
data: {
title: 'login.reset-password-link-expired',
module: 'public',
passwordLinkExpired: true
},
canActivate: [AuthGuard]
} }
]; ];

4
ui-ngx/src/app/modules/login/login.module.ts

@ -24,6 +24,7 @@ import { ResetPasswordRequestComponent } from '@modules/login/pages/login/reset-
import { ResetPasswordComponent } from '@modules/login/pages/login/reset-password.component'; import { ResetPasswordComponent } from '@modules/login/pages/login/reset-password.component';
import { CreatePasswordComponent } from '@modules/login/pages/login/create-password.component'; import { CreatePasswordComponent } from '@modules/login/pages/login/create-password.component';
import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-factor-auth-login.component'; import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-factor-auth-login.component';
import { LinkExpiredComponent } from '@modules/login/pages/login/link-expired.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -31,7 +32,8 @@ import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-fact
ResetPasswordRequestComponent, ResetPasswordRequestComponent,
ResetPasswordComponent, ResetPasswordComponent,
CreatePasswordComponent, CreatePasswordComponent,
TwoFactorAuthLoginComponent TwoFactorAuthLoginComponent,
LinkExpiredComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,

40
ui-ngx/src/app/modules/login/pages/login/link-expired.component.html

@ -0,0 +1,40 @@
<!--
Copyright © 2016-2024 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.
-->
<div class="tb-expired-link-content mat-app-background tb-dark tb-flex row center align-center"
style="width: 100%;">
<mat-card appearance="raised" class="tb-expired-link-card">
<mat-card-header style="justify-content: center">
<mat-card-title>
<span class="mat-headline-5">{{ title | translate }}</span>
</mat-card-title>
</mat-card-header>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<span style="height: 4px;" *ngIf="!(isLoading$ | async)"></span>
<mat-card-content style="padding: 24px 16px">
<div>{{ message | translate }}</div>
</mat-card-content>
<mat-card-actions class="tb-flex row center">
<button mat-raised-button color="accent"
[disabled]="(isLoading$ | async)"
(click)="navigateToLoginPage()">
{{ 'login.login' | translate }}
</button>
</mat-card-actions>
</mat-card>
</div>

32
ui-ngx/src/app/modules/login/pages/login/link-expired.component.scss

@ -0,0 +1,32 @@
/**
* Copyright © 2016-2024 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 '../../../../../scss/constants';
:host {
display: flex;
flex: 1 1 0;
.tb-expired-link-content {
background-color: #eee;
.tb-expired-link-card {
letter-spacing: 0.15px;
line-height: 24px;
padding: 24px;
@media #{$mat-gt-xs} {
width: 486px !important;
}
}
}
}

47
ui-ngx/src/app/modules/login/pages/login/link-expired.component.ts

@ -0,0 +1,47 @@
///
/// Copyright © 2016-2024 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 } from '@angular/core';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { PageComponent } from '@shared/components/page.component';
import { ActivatedRoute, Router } from '@angular/router';
@Component({
selector: 'tb-link-expired',
templateUrl: './link-expired.component.html',
styleUrls: ['./link-expired.component.scss']
})
export class LinkExpiredComponent extends PageComponent {
isPasswordLinkExpired: boolean;
title: string;
message: string;
constructor(protected store: Store<AppState>,
private route: ActivatedRoute,
private router: Router) {
super(store);
this.isPasswordLinkExpired = this.route.snapshot.data.passwordLinkExpired;
this.title = this.isPasswordLinkExpired ? 'login.reset-password-link-expired' : 'login.activation-link-expired';
this.message = this.isPasswordLinkExpired ? 'login.reset-password-link-expired-message' :
'login.activation-link-expired-message';
}
navigateToLoginPage() {
this.router.navigateByUrl('login');
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save