diff --git a/application/src/main/data/json/system/scada_symbols/stand-filter.svg b/application/src/main/data/json/system/scada_symbols/sand-filter.svg similarity index 99% rename from application/src/main/data/json/system/scada_symbols/stand-filter.svg rename to application/src/main/data/json/system/scada_symbols/sand-filter.svg index ebd1e28edd..1f9c0293d9 100644 --- a/application/src/main/data/json/system/scada_symbols/stand-filter.svg +++ b/application/src/main/data/json/system/scada_symbols/sand-filter.svg @@ -1,9 +1,10 @@ { - "title": "Stand filter", - "description": "Stand filter with configurable filtration mode option and various states.", + "title": "Sand filter", + "description": "Sand filter with configurable filtration mode option and various states.", "searchTags": [ - "filter" + "filter", + "sand" ], "widgetSizeX": 3, "widgetSizeY": 5, diff --git a/application/src/main/data/json/system/widget_bundles/scada_fluid_system.json b/application/src/main/data/json/system/widget_bundles/scada_fluid_system.json new file mode 100644 index 0000000000..bcb92dce1d --- /dev/null +++ b/application/src/main/data/json/system/widget_bundles/scada_fluid_system.json @@ -0,0 +1,74 @@ +{ + "widgetsBundle": { + "alias": "scada_fluid_system", + "title": "SCADA fluid system", + "image": "tb-image:c2NhZGFfZmx1aWRfc3lzdGVtX2J1bmRsZV9pbWFnZV8oMSkucG5n:IlNDQURBIGZsdWlkIHN5c3RlbSIgc3lzdGVtIGJ1bmRsZSBpbWFnZQ==:SU1BR0U=;data:image/png;base64,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", + "scada": true, + "description": "Bundle with SCADA symbols for fluid system", + "order": 9300, + "name": "SCADA fluid system" + }, + "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", + "sand_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" + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/scada_symbols.json b/application/src/main/data/json/system/widget_bundles/scada_symbols.json index f1fbfbc297..4de61d2b05 100644 --- a/application/src/main/data/json/system/widget_bundles/scada_symbols.json +++ b/application/src/main/data/json/system/widget_bundles/scada_symbols.json @@ -3,7 +3,7 @@ "alias": "scada_symbols", "title": "SCADA symbols", "scada": true, - "image": null, + "image": "tb-image:c2NhZGFfc3ltYm9sc19zeXN0ZW1fYnVuZGxlX2ltYWdlXygxKS5zdmc=:IlNDQURBIHN5bWJvbHMiIHN5c3RlbSBidW5kbGUgaW1hZ2U=:SU1BR0U=;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJtMTI2LjA0MiAzNS42NjY2LTIyLjkxNy0xMy4yNWMtMS45MTctMS4xMjUtNC4zMzMzLTEuMTI1LTYuMjUgMGwtMjIuOTE2NyAxMy4yNWMtMS45MTY2IDEuMTI1LTMuMTI1IDMuMTY2Ny0zLjEyNSA1LjQxNjd2MjYuNDU4M2MwIDIuMjUgMS4yMDg0IDQuMjkxNiAzLjEyNSA1LjQxNjZsMjIuOTE2NyAxMy4yNWMxLjkxNjcgMS4xMjUgNC4zMzMgMS4xMjUgNi4yNSAwbDIyLjkxNy0xMy4yNWMxLjkxNi0xLjEyNSAzLjEyNS0zLjE2NjYgMy4xMjUtNS40MTY2VjQxLjA4MzNjMC0yLjI1LTEuMjA5LTQuMjkxNy0zLjEyNS01LjQxNjdaTTc5LjE2NjcgNjYuMzMzM3YtMTkuMjVsMTYuNjY2NiA5LjY2NjZ2MTkuMjA4NGwtMTYuNjY2Ni05LjYyNVpNMTAwIDQ5LjU0MTZsLTE2LjY2NjctOS42NjY3TDEwMCAzMC4yNDk5bDE2LjY2NyA5LjYyNUwxMDAgNDkuNTQxNlptNC4xNjcgMjYuNDE2N1Y1Ni43NDk5bDE2LjY2Ni05LjY2NjZ2MTkuMjVsLTE2LjY2NiA5LjYyNVptLTI1LjAwMDMtNjMuNjI1SDY0LjU4MzNjLTMuNDU4MyAwLTYuMjUgMi43OTE2LTYuMjUgNi4yNXYxNC41ODMzaDguMzMzNHYtMTIuNWgxMi41di04LjMzMzNabTQxLjY2NjMgMGgxNC41ODRjMy40NTggMCA2LjI1IDIuNzkxNiA2LjI1IDYuMjV2MTQuNTgzM2gtOC4zMzR2LTEyLjVoLTEyLjV2LTguMzMzM1pNNzkuMTY2NyA5NS42NjY2SDY0LjU4MzNjLTMuNDU4MyAwLTYuMjUtMi43OTE3LTYuMjUtNi4yNVY3NC44MzMyaDguMzMzNHYxMi41MDAxaDEyLjV2OC4zMzMzWm00MS42NjYzIDBoMTQuNTg0YzMuNDU4IDAgNi4yNS0yLjc5MTcgNi4yNS02LjI1Vjc0LjgzMzJoLTguMzM0djEyLjUwMDFoLTEyLjV2OC4zMzMzWiIgZmlsbD0iIzMwNTY4MCIgZmlsbC1vcGFjaXR5PSIuNiIvPjxwYXRoIGQ9Ik02Mi41ODI4IDEyMS42MjVoNC4wMjM1Yy44NjQ1IDAgMS42MDE1LjEzIDIuMjEwOS4zOTEuNjA5NC4yNiAxLjA3NTUuNjQ1IDEuMzk4NCAxLjE1Ni4zMjgyLjUwNS40OTIyIDEuMTMuNDkyMiAxLjg3NSAwIC41NjgtLjEwNDIgMS4wNjgtLjMxMjUgMS41LS4yMDgzLjQzMi0uNTAyNi43OTctLjg4MjggMS4wOTQtLjM4MDIuMjkxLS44MzMzLjUxOC0xLjM1OTQuNjc5bC0uNTkzNy4yODloLTMuNjE3MmwtLjAxNTYtMS41NTRoMi43MTA5Yy40Njg4IDAgLjg1OTQtLjA4NCAxLjE3MTktLjI1LjMxMjUtLjE2Ny41NDY5LS4zOTQuNzAzMS0uNjguMTYxNS0uMjkyLjI0MjItLjYyLjI0MjItLjk4NCAwLS4zOTYtLjA3ODEtLjc0LS4yMzQ0LTEuMDMyLS4xNTEtLjI5Ny0uMzg1NC0uNTIzLS43MDMxLS42NzktLjMxNzctLjE2Mi0uNzIxNC0uMjQyLTEuMjEwOS0uMjQyaC0yLjA2MjVWMTMzaC0xLjk2MXYtMTEuMzc1Wk02OS4wNzUgMTMzbC0yLjY3MTktNS4xMDkgMi4wNTQ3LS4wMDggMi43MTEgNS4wMTVWMTMzSDY5LjA3NVptNy4zMjUuMTU2Yy0uNjI1IDAtMS4xOTAxLS4xMDEtMS42OTUzLS4zMDQtLjUtLjIwOS0uOTI3MS0uNDk4LTEuMjgxMy0uODY4LS4zNDg5LS4zNjktLjYxNzEtLjgwNC0uODA0Ni0xLjMwNHMtLjI4MTMtMS4wMzktLjI4MTMtMS42MTh2LS4zMTJjMC0uNjYxLjA5NjQtMS4yNi4yODkxLTEuNzk3LjE5MjctLjUzNi40NjA5LS45OTUuODA0Ny0xLjM3NS4zNDM3LS4zODUuNzUtLjY4IDEuMjE4Ny0uODgzLjQ2ODgtLjIwMy45NzY2LS4zMDQgMS41MjM0LS4zMDQuNjA0MiAwIDEuMTMyOS4xMDEgMS41ODYuMzA0LjQ1MzEuMjAzLjgyODEuNDkgMS4xMjUuODYuMzAyMS4zNjQuNTI2Ljc5OS42NzE5IDEuMzA0LjE1MS41MDYuMjI2NSAxLjA2My4yMjY1IDEuNjcydi44MDVoLTYuNTMxMnYtMS4zNTJoNC42NzE4di0uMTQ4Yy0uMDEwNC0uMzM5LS4wNzgxLS42NTYtLjIwMzEtLjk1My0uMTE5OC0uMjk3LS4zMDQ3LS41MzctLjU1NDctLjcxOS0uMjUtLjE4Mi0uNTgzMy0uMjczLTEtLjI3My0uMzEyNSAwLS41OTExLjA2Ny0uODM1OS4yMDMtLjIzOTYuMTMtLjQ0MDEuMzItLjYwMTYuNTctLjE2MTQuMjUtLjI4NjQuNTUyLS4zNzUuOTA2LS4wODMzLjM0OS0uMTI1Ljc0Mi0uMTI1IDEuMTh2LjMxMmMwIC4zNy4wNDk1LjcxNC4xNDg1IDEuMDMyLjEwNDEuMzEyLjI1NTIuNTg2LjQ1MzEuODIuMTk3OS4yMzQuNDM3NS40MTkuNzE4Ny41NTUuMjgxMy4xMy42MDE2LjE5NS45NjEuMTk1LjQ1MzEgMCAuODU2Ny0uMDkxIDEuMjEwOS0uMjczLjM1NDItLjE4My42NjE1LS40NC45MjE5LS43NzRsLjk5MjIuOTYxYy0uMTgyMy4yNjYtLjQxOTMuNTIxLS43MTEuNzY2LS4yOTE2LjIzOS0uNjQ4NC40MzUtMS4wNzAzLjU4Ni0uNDE2Ni4xNTEtLjkwMS4yMjYtMS40NTMxLjIyNlptOS43OTM3LTIuNDQ1YzAtLjE4OC0uMDQ2OC0uMzU3LS4xNDA2LS41MDgtLjA5MzctLjE1Ni0uMjczNC0uMjk3LS41MzktLjQyMi0uMjYwNS0uMTI1LS42NDU5LS4yMzktMS4xNTYzLS4zNDMtLjQ0NzktLjA5OS0uODU5NC0uMjE3LTEuMjM0NC0uMzUyLS4zNjk4LS4xNDEtLjY4NzUtLjMxLS45NTMxLS41MDgtLjI2NTYtLjE5OC0uNDcxMy0uNDMyLS42MTcyLS43MDMtLjE0NTgtLjI3MS0uMjE4Ny0uNTgzLS4yMTg3LS45MzcgMC0uMzQ0LjA3NTUtLjY3LjIyNjUtLjk3Ny4xNTExLS4zMDcuMzY3Mi0uNTc4LjY0ODUtLjgxMy4yODEyLS4yMzQuNjIyNC0uNDE5IDEuMDIzNC0uNTU0LjQwNjMtLjEzNi44NTk0LS4yMDMgMS4zNTk0LS4yMDMuNzA4MyAwIDEuMzE1MS4xMTkgMS44MjAzLjM1OS41MTA0LjIzNC45MDEuNTU1IDEuMTcxOS45NjEuMjcwOC40MDEuNDA2Mi44NTQuNDA2MiAxLjM1OWgtMS44ODI4YzAtLjIyNC0uMDU3My0uNDMyLS4xNzE5LS42MjUtLjEwOTMtLjE5OC0uMjc2LS4zNTYtLjUtLjQ3Ni0uMjIzOS0uMTI1LS41MDUyLS4xODgtLjg0MzctLjE4OC0uMzIyOSAwLS41OTEyLjA1Mi0uODA0Ny4xNTctLjIwODMuMDk4LS4zNjQ2LjIyOS0uNDY4OC4zOWExIDEgMCAwIDAtLjE0ODQuNTMxYzAgLjE0MS4wMjYxLjI2OS4wNzgxLjM4My4wNTczLjExLjE1MTEuMjExLjI4MTMuMzA1LjEzMDIuMDg4LjMwNzMuMTcyLjUzMTIuMjUuMjI5Mi4wNzguNTE1Ny4xNTQuODU5NC4yMjYuNjQ1OC4xMzYgMS4yMDA1LjMxIDEuNjY0MS41MjQuNDY4Ny4yMDguODI4MS40NzkgMS4wNzgxLjgxMi4yNS4zMjkuMzc1Ljc0NS4zNzUgMS4yNSAwIC4zNzUtLjA4MDcuNzE5LS4yNDIyIDEuMDMyLS4xNTYyLjMwNy0uMzg1NC41NzUtLjY4NzUuODA0LS4zMDIxLjIyNC0uNjY0MS4zOTktMS4wODU5LjUyNC0uNDE2Ny4xMjUtLjg4NTQuMTg3LTEuNDA2My4xODctLjc2NTYgMC0xLjQxNC0uMTM1LTEuOTQ1My0uNDA2LS41MzEyLS4yNzYtLjkzNDktLjYyOC0xLjIxMDktMS4wNTUtLjI3MDktLjQzMi0uNDA2My0uODgtLjQwNjMtMS4zNDNoMS44MjAzYy4wMjA5LjM0OS4xMTcyLjYyNy4yODkxLjgzNi4xNzcxLjIwMy4zOTU4LjM1MS42NTYyLjQ0NS4yNjU3LjA4OC41MzkxLjEzMy44MjA0LjEzMy4zMzg1IDAgLjYyMjQtLjA0NS44NTE1LS4xMzMuMjI5Mi0uMDk0LjQwMzctLjIxOS41MjM1LS4zNzUuMTE5OC0uMTYyLjE3OTYtLjM0NC4xNzk2LS41NDdabTMuMzMyOS0xLjg0NHYtLjE3OWMwLS42MS4wODg1LTEuMTc1LjI2NTYtMS42OTYuMTc3MS0uNTI2LjQzMjMtLjk4Mi43NjU2LTEuMzY3LjMzODYtLjM5MS43NS0uNjkzIDEuMjM0NC0uOTA2LjQ4OTYtLjIxOSAxLjA0MTctLjMyOCAxLjY1NjItLjMyOC42MTk4IDAgMS4xNzE5LjEwOSAxLjY1NjMuMzI4LjQ4OTYuMjEzLjkwMzYuNTE1IDEuMjQyMi45MDYuMzM4NS4zODUuNTk2My44NDEuNzczNCAxLjM2Ny4xNzcxLjUyMS4yNjU2IDEuMDg2LjI2NTYgMS42OTZ2LjE3OWMwIC42MS0uMDg4NSAxLjE3NS0uMjY1NiAxLjY5NS0uMTc3MS41MjEtLjQzNDkuOTc3LS43NzM0IDEuMzY4LS4zMzg2LjM4NS0uNzUuNjg3LTEuMjM0NC45MDYtLjQ4NDQuMjEzLTEuMDMzOS4zMi0xLjY0ODQuMzItLjYxOTggMC0xLjE3NDUtLjEwNy0xLjY2NDEtLjMyLS40ODQ0LS4yMTktLjg5NTgtLjUyMS0xLjIzNDQtLjkwNi0uMzM4NS0uMzkxLS41OTYzLS44NDctLjc3MzQtMS4zNjgtLjE3NzEtLjUyLS4yNjU2LTEuMDg1LS4yNjU2LTEuNjk1Wm0xLjg4MjgtLjE3OXYuMTc5YzAgLjM4LjAzOS43NC4xMTcyIDEuMDc4LjA3ODEuMzM5LjIwMDUuNjM2LjM2NzIuODkxLjE2NjYuMjU1LjM4MDIuNDU2LjY0MDYuNjAyLjI2MDQuMTQ1LjU3MDMuMjE4LjkyOTcuMjE4LjM0ODkgMCAuNjUxLS4wNzMuOTA2Mi0uMjE4LjI2MDQtLjE0Ni40NzQtLjM0Ny42NDA2LS42MDIuMTY2Ny0uMjU1LjI4OTEtLjU1Mi4zNjcyLS44OTEuMDgzNC0uMzM4LjEyNS0uNjk4LjEyNS0xLjA3OHYtLjE3OWMwLS4zNzYtLjA0MTYtLjczLS4xMjUtMS4wNjMtLjA3ODEtLjMzOS0uMjAzMS0uNjM4LS4zNzUtLjg5OC0uMTY2Ni0uMjYxLS4zODAyLS40NjQtLjY0MDYtLjYxLS4yNTUyLS4xNTEtLjU1OTktLjIyNi0uOTE0MS0uMjI2LS4zNTQxIDAtLjY2MTQuMDc1LS45MjE4LjIyNi0uMjU1Mi4xNDYtLjQ2NjIuMzQ5LS42MzI4LjYxLS4xNjY3LjI2LS4yODkxLjU1OS0uMzY3Mi44OTgtLjA3ODIuMzMzLS4xMTcyLjY4Ny0uMTE3MiAxLjA2M1ptMTIuODQwNiAyLjMydi02LjQ2MWgxLjg5MVYxMzNoLTEuNzgybC0uMTA5LTEuOTkyWm0uMjY2LTEuNzU4LjYzMi0uMDE2YzAgLjU2OC0uMDYyIDEuMDkyLS4xODcgMS41NzEtLjEyNS40NzQtLjMxOC44ODgtLjU3OCAxLjI0Mi0uMjYxLjM0OS0uNTk0LjYyMi0xIC44Mi0uNDA2LjE5My0uODkzLjI4OS0xLjQ2MS4yODktLjQxMiAwLS43ODktLjA2LTEuMTMzLS4xNzktLjM0NC0uMTItLjY0MS0uMzA1LS44OTA2LS41NTUtLjI0NDgtLjI1LS40MzQ5LS41NzYtLjU3MDMtLjk3Ny0uMTM1NC0uNDAxLS4yMDMxLS44OC0uMjAzMS0xLjQzN3YtNS40NjFoMS44ODN2NS40NzZjMCAuMzA4LjAzNi41NjYuMTA5Ljc3NC4wNzMuMjAzLjE3Mi4zNjcuMjk3LjQ5MnMuMjcxLjIxNC40MzguMjY2Yy4xNjYuMDUyLjM0My4wNzguNTMxLjA3OC41MzYgMCAuOTU4LS4xMDQgMS4yNjUtLjMxMy4zMTMtLjIxMy41MzQtLjUuNjY0LS44NTkuMTM2LS4zNTkuMjA0LS43NjMuMjA0LTEuMjExWm01LjY0NS0zLjA5NFYxMzNoLTEuODgzdi04LjQ1M2gxLjc5N2wuMDg2IDEuNjA5Wm0yLjU4Ni0xLjY2NC0uMDE2IDEuNzVhMy43NTc0IDMuNzU3NCAwIDAgMC0uMzc1LS4wNDdjLS4xMy0uMDEtLjI2LS4wMTUtLjM5LS4wMTUtLjMyMyAwLS42MDcuMDQ3LS44NTIuMTQtLjI0NS4wODktLjQ1LjIxOS0uNjE3LjM5MS0uMTYyLjE2Ny0uMjg3LjM3LS4zNzUuNjA5LS4wODkuMjQtLjE0MS41MDgtLjE1Ni44MDVsLS40My4wMzFjMC0uNTMxLjA1Mi0xLjAyMy4xNTYtMS40NzYuMTA0LS40NTMuMjYxLS44NTIuNDY5LTEuMTk2LjIxMy0uMzQzLjQ3OS0uNjEyLjc5Ny0uODA0LjMyMy0uMTkzLjY5NS0uMjg5IDEuMTE3LS4yODkuMTE1IDAgLjIzNy4wMS4zNjcuMDMxLjEzNi4wMjEuMjM3LjA0NC4zMDUuMDdabTQuNjc2IDcuMTY0Yy4zMDggMCAuNTg0LS4wNi44MjktLjE3OS4yNS0uMTI1LjQ1LS4yOTcuNjAxLS41MTYuMTU2LS4yMTkuMjQyLS40NzEuMjU4LS43NThoMS43NzNjLS4wMS41NDctLjE3MSAxLjA0NC0uNDg0IDEuNDkyLS4zMTIuNDQ4LS43MjcuODA1LTEuMjQyIDEuMDcxLS41MTYuMjYtMS4wODYuMzktMS43MTEuMzktLjY0NiAwLTEuMjA4LS4xMDktMS42ODgtLjMyOC0uNDc5LS4yMjQtLjg3Ny0uNTMxLTEuMTk1LS45MjItLjMxOC0uMzktLjU1Ny0uODQxLS43MTktMS4zNTEtLjE1Ni0uNTExLS4yMzQtMS4wNTgtLjIzNC0xLjY0MXYtLjI3M2MwLS41ODQuMDc4LTEuMTMxLjIzNC0xLjY0MS4xNjItLjUxNi40MDEtLjk2OS43MTktMS4zNTkuMzE4LS4zOTEuNzE2LS42OTYgMS4xOTUtLjkxNC40OC0uMjI0IDEuMDM5LS4zMzYgMS42OC0uMzM2LjY3NyAwIDEuMjcxLjEzNSAxLjc4MS40MDYuNTExLjI2NS45MTIuNjM4IDEuMjAzIDEuMTE3LjI5Ny40NzQuNDUxIDEuMDI2LjQ2MSAxLjY1NmgtMS43NzNjLS4wMTYtLjMxMi0uMDk0LS41OTMtLjIzNC0uODQzLS4xMzYtLjI1Ni0uMzI5LS40NTktLjU3OS0uNjEtLjI0NC0uMTUxLS41MzktLjIyNi0uODgyLS4yMjYtLjM4MSAwLS42OTYuMDc4LS45NDYuMjM0LS4yNS4xNTEtLjQ0NS4zNTktLjU4Ni42MjUtLjE0LjI2LS4yNDIuNTU1LS4zMDQuODgzLS4wNTguMzIzLS4wODYuNjU5LS4wODYgMS4wMDh2LjI3M2MwIC4zNDkuMDI4LjY4OC4wODYgMS4wMTYuMDU3LjMyOC4xNTYuNjIyLjI5Ny44ODIuMTQ1LjI1Ni4zNDMuNDYxLjU5My42MTguMjUuMTUxLjU2OC4yMjYuOTUzLjIyNlptOC44MzMgMS41Yy0uNjI1IDAtMS4xOS0uMTAxLTEuNjk1LS4zMDQtLjUtLjIwOS0uOTI3LS40OTgtMS4yODEtLjg2OC0uMzQ5LS4zNjktLjYxNy0uODA0LS44MDUtMS4zMDQtLjE4Ny0uNS0uMjgxLTEuMDM5LS4yODEtMS42MTh2LS4zMTJjMC0uNjYxLjA5Ni0xLjI2LjI4OS0xLjc5Ny4xOTMtLjUzNi40NjEtLjk5NS44MDUtMS4zNzUuMzQzLS4zODUuNzUtLjY4IDEuMjE4LS44ODMuNDY5LS4yMDMuOTc3LS4zMDQgMS41MjQtLjMwNC42MDQgMCAxLjEzMy4xMDEgMS41ODYuMzA0LjQ1My4yMDMuODI4LjQ5IDEuMTI1Ljg2LjMwMi4zNjQuNTI2Ljc5OS42NzIgMS4zMDQuMTUxLjUwNi4yMjYgMS4wNjMuMjI2IDEuNjcydi44MDVoLTYuNTMxdi0xLjM1Mmg0LjY3MnYtLjE0OGMtLjAxMS0uMzM5LS4wNzgtLjY1Ni0uMjAzLS45NTMtLjEyLS4yOTctLjMwNS0uNTM3LS41NTUtLjcxOS0uMjUtLjE4Mi0uNTgzLS4yNzMtMS0uMjczLS4zMTMgMC0uNTkxLjA2Ny0uODM2LjIwMy0uMjQuMTMtLjQ0LjMyLS42MDIuNTctLjE2MS4yNS0uMjg2LjU1Mi0uMzc1LjkwNi0uMDgzLjM0OS0uMTI1Ljc0Mi0uMTI1IDEuMTh2LjMxMmMwIC4zNy4wNS43MTQuMTQ5IDEuMDMyLjEwNC4zMTIuMjU1LjU4Ni40NTMuODIuMTk4LjIzNC40MzcuNDE5LjcxOS41NTUuMjgxLjEzLjYwMS4xOTUuOTYxLjE5NS40NTMgMCAuODU2LS4wOTEgMS4yMTEtLjI3My4zNTQtLjE4My42NjEtLjQ0LjkyMS0uNzc0bC45OTMuOTYxYy0uMTgzLjI2Ni0uNDIuNTIxLS43MTEuNzY2LS4yOTIuMjM5LS42NDkuNDM1LTEuMDcxLjU4Ni0uNDE2LjE1MS0uOTAxLjIyNi0xLjQ1My4yMjZabTkuNzk0LTIuNDQ1YzAtLjE4OC0uMDQ3LS4zNTctLjE0MS0uNTA4LS4wOTMtLjE1Ni0uMjczLS4yOTctLjUzOS0uNDIyLS4yNi0uMTI1LS42NDYtLjIzOS0xLjE1Ni0uMzQzLS40NDgtLjA5OS0uODU5LS4yMTctMS4yMzQtLjM1Mi0uMzctLjE0MS0uNjg4LS4zMS0uOTUzLS41MDgtLjI2Ni0uMTk4LS40NzItLjQzMi0uNjE4LS43MDMtLjE0NS0uMjcxLS4yMTgtLjU4My0uMjE4LS45MzcgMC0uMzQ0LjA3NS0uNjcuMjI2LS45NzcuMTUxLS4zMDcuMzY3LS41NzguNjQ5LS44MTMuMjgxLS4yMzQuNjIyLS40MTkgMS4wMjMtLjU1NC40MDYtLjEzNi44NTktLjIwMyAxLjM1OS0uMjAzLjcwOSAwIDEuMzE2LjExOSAxLjgyMS4zNTkuNTEuMjM0LjkwMS41NTUgMS4xNzIuOTYxLjI3LjQwMS40MDYuODU0LjQwNiAxLjM1OWgtMS44ODNjMC0uMjI0LS4wNTctLjQzMi0uMTcyLS42MjUtLjEwOS0uMTk4LS4yNzYtLjM1Ni0uNS0uNDc2LS4yMjQtLjEyNS0uNTA1LS4xODgtLjg0NC0uMTg4LS4zMjIgMC0uNTkxLjA1Mi0uODA0LjE1Ny0uMjA5LjA5OC0uMzY1LjIyOS0uNDY5LjM5LS4wOTkuMTYyLS4xNDguMzM5LS4xNDguNTMxIDAgLjE0MS4wMjYuMjY5LjA3OC4zODMuMDU3LjExLjE1MS4yMTEuMjgxLjMwNS4xMy4wODguMzA3LjE3Mi41MzEuMjUuMjI5LjA3OC41MTYuMTU0Ljg2LjIyNi42NDUuMTM2IDEuMi4zMSAxLjY2NC41MjQuNDY4LjIwOC44MjguNDc5IDEuMDc4LjgxMi4yNS4zMjkuMzc1Ljc0NS4zNzUgMS4yNSAwIC4zNzUtLjA4MS43MTktLjI0MiAxLjAzMi0uMTU3LjMwNy0uMzg2LjU3NS0uNjg4LjgwNC0uMzAyLjIyNC0uNjY0LjM5OS0xLjA4Ni41MjQtLjQxNy4xMjUtLjg4NS4xODctMS40MDYuMTg3LS43NjYgMC0xLjQxNC0uMTM1LTEuOTQ1LS40MDYtLjUzMi0uMjc2LS45MzUtLjYyOC0xLjIxMS0xLjA1NS0uMjcxLS40MzItLjQwNy0uODgtLjQwNy0xLjM0M2gxLjgyMWMuMDIxLjM0OS4xMTcuNjI3LjI4OS44MzYuMTc3LjIwMy4zOTYuMzUxLjY1Ni40NDUuMjY2LjA4OC41MzkuMTMzLjgyLjEzMy4zMzkgMCAuNjIzLS4wNDUuODUyLS4xMzMuMjI5LS4wOTQuNDA0LS4yMTkuNTIzLS4zNzUuMTItLjE2Mi4xOC0uMzQ0LjE4LS41NDdaTTYxLjEyNDIgMTQ5Ljg0NGMwLS4xOTktLjAzMTItLjM3NS0uMDkzNy0uNTI4LS4wNTg2LS4xNTYtLjE2NDEtLjI5Ni0uMzE2NC0uNDIxLS4xNDg1LS4xMjUtLjM1NTUtLjI0NS0uNjIxMS0uMzU4LS4yNjE4LS4xMTMtLjU5MzgtLjIyOC0uOTk2MS0uMzQ2LS40MjE5LS4xMjUtLjgwMjgtLjI2My0xLjE0MjYtLjQxNi0uMzM5OC0uMTU2LS42MzA5LS4zMzQtLjg3MzEtLjUzMy0uMjQyMS0uMTk5LS40Mjc3LS40MjgtLjU1NjYtLjY4NS0uMTI4OS0uMjU4LS4xOTM0LS41NTMtLjE5MzQtLjg4NSAwLS4zMzIuMDY4NC0uNjM5LjIwNTEtLjkycy4zMzIxLS41MjUuNTg2LS43MzJjLjI1NzgtLjIxMS41NjQ0LS4zNzUuOTE5OS0uNDkzLjM1NTUtLjExNy43NTE5LS4xNzUgMS4xODk0LS4xNzUuNjQwNyAwIDEuMTgzNi4xMjMgMS42Mjg5LjM2OS40NDkzLjI0Mi43OTExLjU2IDEuMDI1NC45NTUuMjM0NC4zOS4zNTE2LjgwOC4zNTE2IDEuMjU0aC0xLjEyNWMwLS4zMjEtLjA2ODQtLjYwNC0uMjA1MS0uODUtLjEzNjctLjI1LS4zNDM3LS40NDUtLjYyMTEtLjU4Ni0uMjc3My0uMTQ0LS42Mjg5LS4yMTctMS4wNTQ3LS4yMTctLjQwMjMgMC0uNzM0My4wNjEtLjk5NjEuMTgyLS4yNjE3LjEyMS0uNDU3LjI4NS0uNTg1OS40OTItLjEyNS4yMDctLjE4NzUuNDQ0LS4xODc1LjcwOSAwIC4xOC4wMzcxLjM0NC4xMTEzLjQ5Mi4wNzgyLjE0NS4xOTczLjI4LjM1NzUuNDA1LjE2NC4xMjUuMzcxMS4yNC42MjExLjM0NS4yNTM5LjEwNi41NTY2LjIwNy45MDgyLjMwNS40ODQzLjEzNy45MDIzLjI4OSAxLjI1MzkuNDU3LjM1MTUuMTY4LjY0MDYuMzU3Ljg2NzEuNTY4LjIzMDUuMjA3LjQwMDQuNDQ0LjUwOTguNzA5LjExMzMuMjYyLjE2OTkuNTU5LjE2OTkuODkxIDAgLjM0OC0uMDcwMy42NjItLjIxMDkuOTQzLS4xNDA2LjI4Mi0uMzQxOC41MjItLjYwMzUuNzIxLS4yNjE3LjE5OS0uNTc2Mi4zNTQtLjk0MzQuNDYzLS4zNjMzLjEwNS0uNzY5NS4xNTgtMS4yMTg3LjE1OGE0LjIzOCA0LjIzOCAwIDAgMS0xLjE2Ni0uMTY0Yy0uMzc4OS0uMTA5LS43MjQ3LS4yNzMtMS4wMzcyLS40OTItLjMwODUtLjIxOS0uNTU2Ni0uNDg4LS43NDQxLS44MDktLjE4MzYtLjMyNC0uMjc1NC0uNjk5LS4yNzU0LTEuMTI1aDEuMTI1YzAgLjI5My4wNTY3LjU0NS4xNjk5Ljc1Ni4xMTMzLjIwNy4yNjc2LjM3OS40NjI5LjUxNi4xOTkyLjEzNy40MjM5LjIzOC42NzM5LjMwNS4yNTM5LjA2Mi41MTc1LjA5My43OTEuMDkzLjM5NDUgMCAuNzI4NS0uMDU0IDEuMDAxOS0uMTY0LjI3MzUtLjEwOS40ODA1LS4yNjUuNjIxMS0uNDY5LjE0NDYtLjIwMy4yMTY4LS40NDMuMjE2OC0uNzJabTcuOTI4NS0uNTU3aDEuMTI1Yy0uMDU4Ni41MzktLjIxMjkgMS4wMjItLjQ2MjkgMS40NDctLjI1LjQyNi0uNjAzNS43NjQtMS4wNjA1IDEuMDE0LS40NTcuMjQ2LTEuMDI3My4zNjktMS43MTA5LjM2OS0uNSAwLS45NTUxLS4wOTQtMS4zNjUzLS4yODFhMy4wMzc3IDMuMDM3NyAwIDAgMS0xLjA0ODgtLjc5N2MtLjI5My0uMzQ4LS41MTk1LS43NjQtLjY3OTctMS4yNDgtLjE1NjItLjQ4OC0uMjM0NC0xLjAzMS0uMjM0NC0xLjYyOXYtLjg1YzAtLjU5Ny4wNzgyLTEuMTM4LjIzNDQtMS42MjMuMTYwMi0uNDg4LjM4ODctLjkwNi42ODU2LTEuMjUzYTMuMDQ4NiAzLjA0ODYgMCAwIDEgMS4wODM5LS44MDNjLjQyMTktLjE4OC44OTY1LS4yODEgMS40MjM5LS4yODEuNjQ0NSAwIDEuMTg5NC4xMjEgMS42MzQ3LjM2My40NDUzLjI0Mi43OTExLjU3OCAxLjAzNzEgMS4wMDguMjUuNDI1LjQwNDMuOTIuNDYyOSAxLjQ4MmgtMS4xMjVjLS4wNTQ3LS4zOTgtLjE1NjItLjc0LS4zMDQ3LTEuMDI1LS4xNDg0LS4yODktLjM1OTMtLjUxMi0uNjMyOC0uNjY4LS4yNzM0LS4xNTctLjYzMDgtLjIzNS0xLjA3MjItLjIzNS0uMzc4OSAwLS43MTI5LjA3My0xLjAwMi4yMTctLjI4NTEuMTQ1LS41MjU0LjM1LS43MjA3LjYxNS0uMTkxNC4yNjYtLjMzNTkuNTg0LS40MzM2Ljk1NS0uMDk3Ni4zNzItLjE0NjUuNzg0LS4xNDY1IDEuMjM3di44NjFjMCAuNDE4LjA0My44MTEuMTI4OSAxLjE3OC4wODk5LjM2Ny4yMjQ3LjY4OS40MDQzLjk2Ny4xNzk3LjI3Ny40MDgyLjQ5Ni42ODU2LjY1Ni4yNzczLjE1Ni42MDU1LjIzNC45ODQ0LjIzNC40ODA0IDAgLjg2MzItLjA3NiAxLjE0ODQtLjIyOC4yODUyLS4xNTMuNS0uMzcxLjY0NDUtLjY1Ny4xNDg1LS4yODUuMjUzOS0uNjI2LjMxNjQtMS4wMjVabTYuMDI0My01LjA2Mkw3Mi4yNTI3IDE1MmgtMS4xNTQzbDMuMjUyLTguNTMxaC43NDQxbC0uMDE3NS43NTZaTTc3LjQ0NDEgMTUybC0yLjgzLTcuNzc1LS4wMTc2LS43NTZoLjc0NDFMNzguNjA0MyAxNTJoLTEuMTYwMlptLS4xNDY0LTMuMTU4di45MjZoLTQuNzkzdi0uOTI2aDQuNzkzWk04Mi4yNTU1IDE1MmgtMS43ODEzbC4wMTE3LS45MmgxLjc2OTZjLjYwOTMgMCAxLjExNzItLjEyNyAxLjUyMzQtLjM4MS40MDYzLS4yNTguNzEwOS0uNjE3LjkxNDEtMS4wNzguMjA3LS40NjUuMzEwNS0xLjAwOC4zMTA1LTEuNjI5di0uNTIxYzAtLjQ4OS0uMDU4Ni0uOTIyLS4xNzU4LTEuMzAxLS4xMTcyLS4zODMtLjI4OS0uNzA1LS41MTU2LS45NjctLjIyNjYtLjI2NS0uNTAzOS0uNDY3LS44MzItLjYwMy0uMzI0Mi0uMTM3LS42OTczLS4yMDUtMS4xMTkyLS4yMDVoLTEuOTIxOHYtLjkyNmgxLjkyMThjLjU1ODYgMCAxLjA2ODQuMDkzIDEuNTI5My4yODEuNDYxLjE4NC44NTc1LjQ1MSAxLjE4OTUuODAzLjMzNTkuMzQ3LjU5MzcuNzY5Ljc3MzQgMS4yNjUuMTc5Ny40OTMuMjY5NiAxLjA0Ny4yNjk2IDEuNjY0di41MWMwIC42MTctLjA4OTkgMS4xNzQtLjI2OTYgMS42Ny0uMTc5Ny40OTItLjQzOTQuOTEyLS43NzkzIDEuMjYtLjMzNTkuMzQ4LS43NDIyLjYxNS0xLjIxODcuODAzLS40NzI3LjE4My0xLjAwNTkuMjc1LTEuNTk5Ni4yNzVabS0xLjE3NzgtOC41MzFWMTUyaC0xLjEzMDh2LTguNTMxaDEuMTMwOFptOS45NzM1Ljc1Nkw4OC4yMjcgMTUyaC0xLjE1NDNsMy4yNTE5LTguNTMxaC43NDQxbC0uMDE3NS43NTZaTTkzLjQxODQgMTUybC0yLjgzMDEtNy43NzUtLjAxNzYtLjc1NmguNzQ0MUw5NC41Nzg1IDE1MmgtMS4xNjAxWm0tLjE0NjUtMy4xNTh2LjkyNmgtNC43OTN2LS45MjZoNC43OTNabTkuMzUzMSAxLjQ3NmMwLS4xNTYtLjAzNS0uMy0uMTA1LS40MzMtLjA2Ny0uMTM3LS4yMDYtLjI2LS40MTYtLjM2OS0uMjA4LS4xMTQtLjUyLS4yMTEtLjkzOC0uMjkzLS4zNTItLjA3NS0uNjctLjE2Mi0uOTU1LS4yNjQtLjI4MTMtLjEwMi0uNTIxNS0uMjI1LS43MjA4LS4zNjktLjE5NTMtLjE0NS0uMzQ1Ny0uMzE1LS40NTExLS41MS0uMTA1NS0uMTk1LS4xNTgyLS40MjQtLjE1ODItLjY4NSAwLS4yNS4wNTQ2LS40ODcuMTY0LS43MDkuMTEzMy0uMjIzLjI3MTUtLjQyLjQ3NDYtLjU5Mi4yMDcxLS4xNzIuNDU1MS0uMzA3Ljc0NDUtLjQwNS4yODktLjA5Ny42MTEtLjE0Ni45NjYtLjE0Ni41MDggMCAuOTQyLjA5IDEuMzAxLjI2OS4zNi4xOC42MzUuNDIuODI2LjcyMS4xOTIuMjk3LjI4OC42MjcuMjg4Ljk5aC0xLjA4NGMwLS4xNzUtLjA1My0uMzQ1LS4xNTktLjUwOS0uMTAxLS4xNjgtLjI1Mi0uMzA3LS40NTEtLjQxNi0uMTk1LS4xMS0uNDM1LS4xNjQtLjcyMS0uMTY0LS4zIDAtLjU0NC4wNDYtLjczMi4xNC0uMTg0LjA5LS4zMTguMjA1LS40MDQuMzQ2LS4wODIuMTQxLS4xMjMzLjI4OS0uMTIzMy40NDUgMCAuMTE3LjAxOTUuMjIzLjA1ODMuMzE3LjA0My4wODkuMTE3LjE3My4yMjMuMjUyLjEwNS4wNzQuMjU0LjE0NC40NDUuMjExLjE5Mi4wNjYuNDM2LjEzMi43MzMuMTk5LjUxOS4xMTcuOTQ3LjI1OCAxLjI4My40MjIuMzM2LjE2NC41ODYuMzY1Ljc1LjYwMy4xNjQuMjM4LjI0Ni41MjcuMjQ2Ljg2NyAwIC4yNzgtLjA1OS41MzItLjE3Ni43NjItLjExMy4yMzEtLjI3OS40My0uNDk4LjU5OC0uMjE1LjE2NC0uNDczLjI5My0uNzczLjM4Ni0uMjk3LjA5LS42MzEuMTM1LTEuMDAyLjEzNS0uNTU5IDAtMS4wMzEtLjA5OS0xLjQxODItLjI5OS0uMzg2Ny0uMTk5LS42Nzk3LS40NTctLjg3ODktLjc3My0uMTk5Mi0uMzE2LS4yOTg4LS42NS0uMjk4OC0xLjAwMmgxLjA4OThjLjAxNTYuMjk3LjEwMTYuNTMzLjI1ODEuNzA5LjE1Ni4xNzIuMzQ3LjI5NS41NzQuMzY5LjIyNi4wNy40NTEuMTA2LjY3NC4xMDYuMjk3IDAgLjU0NS0uMDM5Ljc0NC0uMTE4LjIwMy0uMDc4LjM1Ny0uMTg1LjQ2My0uMzIyLjEwNS0uMTM3LjE1OC0uMjkzLjE1OC0uNDY5Wm00LjQ4OSAxLjAyNiAxLjc2NC01LjY4NGgxLjE2bC0yLjU0MyA3LjMxOWMtLjA1OS4xNTYtLjEzNy4zMjQtLjIzNC41MDMtLjA5NC4xODQtLjIxNS4zNTgtLjM2NC41MjItLjE0OC4xNjQtLjMyOC4yOTctLjUzOS4zOTgtLjIwNy4xMDYtLjQ1NS4xNTktLjc0NC4xNTktLjA4NiAwLS4xOTUtLjAxMi0uMzI4LS4wMzYtLjEzMy0uMDIzLS4yMjctLjA0My0uMjgxLS4wNThsLS4wMDYtLjg3OWMuMDMxLjAwNC4wOC4wMDguMTQ2LjAxMi4wNzEuMDA3LjExOS4wMTEuMTQ3LjAxMS4yNDYgMCAuNDU1LS4wMzMuNjI3LS4wOTkuMTcyLS4wNjMuMzE2LS4xNy40MzMtLjMyMy4xMjEtLjE0OC4yMjUtLjM1My4zMTEtLjYxNWwuNDUxLTEuMjNabS0xLjI5NS01LjY4NCAxLjY0NyA0LjkyMi4yODEgMS4xNDMtLjc3OS4zOTgtMi4zMzItNi40NjNoMS4xODNabTYuNDc2IDEuMjZWMTUyaC0xLjA5di02LjM0aDEuMDMxbC4wNTkgMS4yNlptLS4yMjMgMS42Ny0uNTA0LS4wMThjLjAwNC0uNDMzLjA2MS0uODM0LjE3LTEuMjAxLjEwOS0uMzcxLjI3MS0uNjkzLjQ4Ni0uOTY3LjIxNS0uMjczLjQ4My0uNDg0LjgwMy0uNjMzLjMyLS4xNTIuNjkxLS4yMjggMS4xMTMtLjIyOC4yOTcgMCAuNTcxLjA0My44MjEuMTI5LjI1LjA4Mi40NjYuMjEzLjY1LjM5Mi4xODQuMTguMzI2LjQxMS40MjguNjkyLjEwMS4yODEuMTUyLjYyMS4xNTIgMS4wMTlWMTUyaC0xLjA4NHYtNC4xNzJjMC0uMzMyLS4wNTctLjU5OC0uMTctLjc5Ny0uMTA5LS4xOTktLjI2Ni0uMzQzLS40NjktLjQzMy0uMjAzLS4wOTQtLjQ0MS0uMTQxLS43MTQtLjE0MS0uMzIxIDAtLjU4OC4wNTctLjgwMy4xNy0uMjE1LjExMy0uMzg3LjI2OS0uNTE2LjQ2OS0uMTI5LjE5OS0uMjIzLjQyNy0uMjgxLjY4NS0uMDU1LjI1NC0uMDgyLjUyNC0uMDgyLjgwOVptNC4xMDctLjU5OC0uNzI2LjIyM2MuMDA0LS4zNDguMDYtLjY4Mi4xNy0xLjAwMi4xMTMtLjMyLjI3NS0uNjA2LjQ4Ni0uODU2LjIxNS0uMjUuNDc4LS40NDcuNzkxLS41OTEuMzEyLS4xNDkuNjctLjIyMyAxLjA3Mi0uMjIzLjM0IDAgLjY0MS4wNDUuOTAzLjEzNS4yNjUuMDkuNDg4LjIyOC42NjguNDE2LjE4My4xODMuMzIyLjQyLjQxNi43MDkuMDkzLjI4OS4xNC42MzMuMTQgMS4wMzFWMTUyaC0xLjA5di00LjE3OGMwLS4zNTUtLjA1Ni0uNjMxLS4xNy0uODI2LS4xMDktLjE5OS0uMjY1LS4zMzgtLjQ2OC0uNDE2LS4yLS4wODItLjQzOC0uMTIzLS43MTUtLjEyMy0uMjM4IDAtLjQ0OS4wNDEtLjYzMy4xMjMtLjE4NC4wODItLjMzOC4xOTUtLjQ2My4zNGExLjQ4NSAxLjQ4NSAwIDAgMC0uMjg3LjQ4NmMtLjA2Mi4xODQtLjA5NC4zNzktLjA5NC41ODZabTUuNzU1LTQuOTkyaDEuMDl2Ny43N2wtLjA5NCAxLjIzaC0uOTk2di05Wm01LjM3MyA1Ljc3N3YuMTIzYzAgLjQ2MS0uMDU1Ljg4OS0uMTY0IDEuMjg0LS4xMDkuMzktLjI3LjczLS40OCAxLjAxOS0uMjExLjI4OS0uNDY5LjUxNC0uNzc0LjY3NC0uMzA1LjE2LS42NTQuMjQtMS4wNDkuMjQtLjQwMiAwLS43NTYtLjA2OC0xLjA2LS4yMDVhMi4wOTM0IDIuMDkzNCAwIDAgMS0uNzYyLS42MDNjLS4yMDctLjI2Mi0uMzczLS41NzktLjQ5OC0uOTUtLjEyMS0uMzcxLS4yMDUtLjc4OS0uMjUyLTEuMjU0di0uNTM5Yy4wNDctLjQ2OC4xMzEtLjg4OC4yNTItMS4yNTkuMTI1LS4zNzEuMjkxLS42ODguNDk4LS45NS4yMDctLjI2NS40NjEtLjQ2Ni43NjItLjYwMy4zLS4xNDEuNjUtLjIxMSAxLjA0OS0uMjExLjM5OCAwIC43NTEuMDc4IDEuMDYuMjM0LjMwOS4xNTMuNTY2LjM3MS43NzQuNjU3LjIxLjI4NS4zNzEuNjI3LjQ4IDEuMDI1LjEwOS4zOTUuMTY0LjgzNC4xNjQgMS4zMThabS0xLjA5LjEyM3YtLjEyM2MwLS4zMTYtLjAyOS0uNjEzLS4wODgtLjg5LS4wNTgtLjI4Mi0uMTUyLS41MjgtLjI4MS0uNzM5LS4xMjktLjIxNC0uMjk5LS4zODItLjUxLS41MDMtLjIxMS0uMTI1LS40Ny0uMTg4LS43NzktLjE4OC0uMjczIDAtLjUxMi4wNDctLjcxNS4xNDEtLjE5OS4wOTMtLjM2OS4yMi0uNTEuMzgxLS4xNC4xNTYtLjI1NS4zMzUtLjM0NS41MzktLjA4Ni4xOTktLjE1MS40MDYtLjE5NC42MjF2MS40MTJjLjA2My4yNzMuMTY0LjUzNy4zMDUuNzkxLjE0NS4yNS4zMzYuNDU1LjU3NC42MTUuMjQyLjE2LjU0MS4yNC44OTcuMjQuMjkzIDAgLjU0My0uMDU4Ljc1LS4xNzYuMjExLS4xMjEuMzgxLS4yODcuNTA5LS40OTguMTMzLS4yMTEuMjMxLS40NTUuMjkzLS43MzIuMDYzLS4yNzcuMDk0LS41NzQuMDk0LS44OTFabTIuMzc0IDB2LS4xMzRjMC0uNDU3LjA2Ni0uODgxLjE5OS0xLjI3Mi4xMzMtLjM5NC4zMjQtLjczNi41NzQtMS4wMjUuMjUtLjI5My41NTMtLjUyLjkwOS0uNjguMzU1LS4xNjQuNzU0LS4yNDYgMS4xOTUtLjI0Ni40NDUgMCAuODQ2LjA4MiAxLjIwMS4yNDYuMzYuMTYuNjY0LjM4Ny45MTQuNjguMjU0LjI4OS40NDcuNjMxLjU4IDEuMDI1LjEzMy4zOTEuMi44MTUuMiAxLjI3MnYuMTM0YzAgLjQ1Ny0uMDY3Ljg4MS0uMiAxLjI3Mi0uMTMzLjM5LS4zMjYuNzMyLS41OCAxLjAyNS0uMjUuMjg5LS41NTMuNTE2LS45MDguNjgtLjM1Mi4xNi0uNzUuMjQtMS4xOTUuMjQtLjQ0NiAwLS44NDYtLjA4LTEuMjAxLS4yNC0uMzU2LS4xNjQtLjY2MS0uMzkxLS45MTUtLjY4LS4yNS0uMjkzLS40NDEtLjYzNS0uNTc0LTEuMDI1LS4xMzMtLjM5MS0uMTk5LS44MTUtLjE5OS0xLjI3MlptMS4wODQtLjEzNHYuMTM0YzAgLjMxNy4wMzcuNjE2LjExMS44OTcuMDc1LjI3Ny4xODYuNTIzLjMzNC43MzguMTUzLjIxNS4zNDIuMzg1LjU2OS41MS4yMjYuMTIxLjQ5LjE4Mi43OTEuMTgyLjI5NyAwIC41NTYtLjA2MS43NzktLjE4Mi4yMjctLjEyNS40MTQtLjI5NS41NjItLjUxLjE0OS0uMjE1LjI2LS40NjEuMzM0LS43MzguMDc5LS4yODEuMTE4LS41OC4xMTgtLjg5N3YtLjEzNGMwLS4zMTMtLjAzOS0uNjA4LS4xMTgtLjg4NS0uMDc0LS4yODEtLjE4Ny0uNTI5LS4zMzktLjc0NC0uMTQ5LS4yMTktLjMzNi0uMzkxLS41NjMtLjUxNi0uMjIzLS4xMjUtLjQ4NC0uMTg3LS43ODUtLjE4Ny0uMjk3IDAtLjU1OS4wNjItLjc4NS4xODctLjIyMy4xMjUtLjQxLjI5Ny0uNTYzLjUxNi0uMTQ4LjIxNS0uMjU5LjQ2My0uMzM0Ljc0NC0uMDc0LjI3Ny0uMTExLjU3Mi0uMTExLjg4NVpNMTM3LjEgMTQzdjloLTEuMDl2LTloMS4wOVptNS42MzEgNy4zMThjMC0uMTU2LS4wMzUtLjMtLjEwNS0uNDMzLS4wNjctLjEzNy0uMjA1LS4yNi0uNDE2LS4zNjktLjIwNy0uMTE0LS41Mi0uMjExLS45MzgtLjI5My0uMzUxLS4wNzUtLjY3LS4xNjItLjk1NS0uMjY0LS4yODEtLjEwMi0uNTIxLS4yMjUtLjcyMS0uMzY5LS4xOTUtLjE0NS0uMzQ1LS4zMTUtLjQ1MS0uNTEtLjEwNS0uMTk1LS4xNTgtLjQyNC0uMTU4LS42ODUgMC0uMjUuMDU1LS40ODcuMTY0LS43MDkuMTEzLS4yMjMuMjcyLS40Mi40NzUtLjU5Mi4yMDctLjE3Mi40NTUtLjMwNy43NDQtLjQwNS4yODktLjA5Ny42MTEtLjE0Ni45NjctLjE0Ni41MDggMCAuOTQxLjA5IDEuMy4yNjkuMzYuMTguNjM1LjQyLjgyNy43MjEuMTkxLjI5Ny4yODcuNjI3LjI4Ny45OWgtMS4wODRjMC0uMTc1LS4wNTMtLjM0NS0uMTU4LS41MDktLjEwMi0uMTY4LS4yNTItLjMwNy0uNDUyLS40MTYtLjE5NS0uMTEtLjQzNS0uMTY0LS43Mi0uMTY0LS4zMDEgMC0uNTQ1LjA0Ni0uNzMzLjE0LS4xODMuMDktLjMxOC4yMDUtLjQwNC4zNDYtLjA4Mi4xNDEtLjEyMy4yODktLjEyMy40NDUgMCAuMTE3LjAxOS4yMjMuMDU5LjMxNy4wNDMuMDg5LjExNy4xNzMuMjIyLjI1Mi4xMDYuMDc0LjI1NC4xNDQuNDQ2LjIxMS4xOTEuMDY2LjQzNS4xMzIuNzMyLjE5OS41MTkuMTE3Ljk0Ny4yNTggMS4yODMuNDIyLjMzNi4xNjQuNTg2LjM2NS43NS42MDMuMTY0LjIzOC4yNDYuNTI3LjI0Ni44NjcgMCAuMjc4LS4wNTguNTMyLS4xNzYuNzYyLS4xMTMuMjMxLS4yNzkuNDMtLjQ5OC41OTgtLjIxNC4xNjQtLjQ3Mi4yOTMtLjc3My4zODYtLjI5Ny4wOS0uNjMxLjEzNS0xLjAwMi4xMzUtLjU1OSAwLTEuMDMxLS4wOTktMS40MTgtLjI5OS0uMzg3LS4xOTktLjY4LS40NTctLjg3OS0uNzczLS4xOTktLjMxNi0uMjk5LS42NS0uMjk5LTEuMDAyaDEuMDljLjAxNi4yOTcuMTAyLjUzMy4yNTguNzA5LjE1Ni4xNzIuMzQ4LjI5NS41NzQuMzY5LjIyNy4wNy40NTEuMTA2LjY3NC4xMDYuMjk3IDAgLjU0NS0uMDM5Ljc0NC0uMTE4LjIwMy0uMDc4LjM1OC0uMTg1LjQ2My0uMzIyLjEwNi0uMTM3LjE1OC0uMjkzLjE1OC0uNDY5WiIgZmlsbD0iIzMwNTY4MCIgZmlsbC1vcGFjaXR5PSIuOCIvPjwvc3ZnPg==", "description": "Bundle with SCADA symbols", "order": 9200, "name": "SCADA symbols" diff --git a/application/src/main/data/json/system/widget_bundles/scada_water_system_symbols.json b/application/src/main/data/json/system/widget_bundles/scada_water_system_symbols.json deleted file mode 100644 index 22575e0697..0000000000 --- a/application/src/main/data/json/system/widget_bundles/scada_water_system_symbols.json +++ /dev/null @@ -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" - ] -} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/scada_symbol.json b/application/src/main/data/json/system/widget_types/scada_symbol.json index 15c12e1b81..bdcdd1674d 100644 --- a/application/src/main/data/json/system/widget_types/scada_symbol.json +++ b/application/src/main/data/json/system/widget_types/scada_symbol.json @@ -3,7 +3,7 @@ "name": "SCADA symbol", "deprecated": false, "scada": true, - "image": null, + "image": "tb-image:bmV3X3NjYWRhX3N5bWJvbHNfc3lzdGVtX3dpZGdldF9pbWFnZV8oMSkuc3Zn:Ik5ldyBTQ0FEQSBzeW1ib2wiIHN5c3RlbSB3aWRnZXQgaW1hZ2U=:SU1BR0U=;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBjbGlwLXBhdGg9InVybCgjYSkiIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjMzA1NjgwIiBmaWxsLW9wYWNpdHk9Ii42Ij48cGF0aCBkPSJtMTQ3LjE2MyA0OC43NTM3LTE0LjcxOC0xNS4zODU1Yy0xLjIxMy0xLjI2MTQtMi42OTMtMi4xOTYtNC4zNC0yLjczOTYtNS4wNzUtNi41NDc1LTExLjE5OS0xMC42Nzc3LTIwLjEzNC0xMC42Nzc3LTguNjkwNiAwLTE2LjQzMDMgMy42MjQxLTIxLjUzMDMgMTAuMzI4SDcyLjQ0NzRjLTUuNTYzMS4wMDA1LTEwLjE1NyA0LjM2Ny0xMC4xNTcgOS45MzA3djM5Ljc5MDVoNS4wMjhWNDAuMjA5NmMwLTIuNzk1NSAyLjMzMzUtNC45MDIyIDUuMTI5LTQuOTAyMmgxMC43MjU3Yy0xLjkxMTIgMy4zNTE5LTMuMDA1NiA3Ljg0MTktMy4wMDU2IDEyLjM1ODZ2My44NDI1aC04LjM2MmMtLjY1MDggMC0xLjIzOC4yNjI1LTEuNDg4My44NjMxLS4yNTAyLjYwMDYtLjExNTYgMS4yMjg1LjM0MiAxLjY5MTFsMjIuNDE3MyAyMi42MjQ1Yy4zMjM0LjMyNjkuNzY0OC40OTUgMS4yMjQ2LjQ5NS40NTk3IDAgLjkwMTEtLjE5MjIgMS4yMjQ1LS41MTlsMjIuMjg4NC0yMi41MzA3Yy40NzktLjQ4NDQuNjIxLTEuMDgyNy4zNTgtMS43MTIzLS4yNjMtLjYyODUtLjg3Ny0uOTExNy0xLjU1OS0uOTExN0gxMDguMVY0Ny42NjZjMC00Ljk3MzIgMi40ODYtOS41NjUzIDUuODk1LTEyLjM1ODZoOS4xMjNjMS45ODEuNTU4NiAzLjQxOCAyLjA3MzcgMy40MTggNC4yMDIydjEwLjgxMTdjMCAyLjkwMTcgMi4zODYgNS42NTY0IDUuMjg3IDUuNjU2NGg4Ljg5OWMyLjUwMiAwIDQuMzg5IDEuODI2MyA0LjM4OSA0LjMyNjlsLS4xMzktLjIwOVYxMzAuMDVjMCAyLjc5NS0yLjAzMiA1LjI1Ny00LjgyNyA1LjI1N0g3Mi40NDc0Yy0yLjc5NTUgMC01LjEyOS0yLjQ2MS01LjEyOS01LjI1N3YtMi41NjRoLTUuMDI4djIuNTY0YzAgNS41NjQgNC41OTM5IDEwLjI4NSAxMC4xNTcgMTAuMjg1aDY3LjY5NzZjNS41NjMgMCA5Ljg1NS00LjcyMSA5Ljg1NS0xMC4yODVWNTYuMDg3OGMwLTIuNzQ4Ni0uOTMyLTUuMzUzMS0yLjgzNy03LjMzNDFaIi8+PHBhdGggZD0iTTEyNy42NTQgODguMTQ5NmMwLTEuNzI0LTEuMzk4LTMuMTIxOC0zLjEyMi0zLjEyMThINTMuMTIxN2MtMS43MjQgMC0zLjEyMTggMS4zOTc4LTMuMTIxOCAzLjEyMTh2MzEuNzQ0NGMwIDEuNzI0IDEuMzk3OCAzLjEyMiAzLjEyMTggMy4xMjJoNzEuNDEwM2MxLjcyNCAwIDMuMTIyLTEuMzk4IDMuMTIyLTMuMTIyVjg4LjE0OTZaTTc2LjEzNjMgMTExLjc1NWMtLjQxMjkuOTE0LTEuMDI3NCAxLjcwMi0xLjg0MzYgMi4zNjYtLjgxNTcuNjYzLTEuODI3NCAxLjE3OS0zLjAzNDcgMS41NDktMS4yMDY3LjM3LTIuNjA1LjU1NS00LjE5MjcuNTU1LTEuMjgzOCAwLTIuNTI4NS0uMTU4LTMuNzM1OC0uNDczLTEuMjA3Mi0uMzE1LTIuMjczMS0uODEtMy4xOTc3LTEuNDg1LS45MjUyLS42NzQtMS42NTkyLTEuNTUyLTIuMjAyMy0yLjU5Ni0uNTQ0MS0xLjA0NC0uODA1LTIuMDYyLS43ODMyLTMuNzM4aDQuOTU5OGMwIDEuMTE3LjE0MTMgMS40NjUuNDI0IDIuMDA5LjI4MjcuNTQ0LjY1ODEuOTk0IDEuMTI2MyAxLjMzMS40Njc2LjMzOCAxLjAxNjIuNTkyIDEuNjQ3NC43NTUuNjMwOC4xNjQgMS4yODMzLjI0NyAxLjk1NzYuMjQ3LjQ1NjQgMCAuOTQ2NC0uMDM3IDEuNDY4MS0uMTEzLjUyMjQtLjA3NiAxLjAxMTgtLjIyMiAxLjQ2ODgtLjQ0LjQ1NjQtLjIxOC44MzY4LS41MTcgMS4xNDE5LS44OTcuMzA0NC0uMzguNDU2NC0uODY1LjQ1NjQtMS40NTIgMC0uNjMxLS4yMDExLTEuMTQyLS42MDM0LTEuNTMzLS40MDI4LS4zOTItLjkzMDEtLjcxOC0xLjU4MjYtLjk3OS0uNjUyNi0uMjYxLTEuMzkyMi0uNDktMi4yMTktLjY4Ni0uODI2My0uMTk1LTEuNjY0My0uNDEzLTIuNTEyMy0uNjUyLS44NzA0LS4yMTgtMS43MTg1LS40ODQtMi41NDQ3LS44LS44MjY4LS4zMTUtMS41NjY1LS43MjMtMi4yMTktMS4yMjMtLjY1MjUtLjUtMS4xODA1LTEuMTI2LTEuNTgyNy0xLjg3Ni0uNDAyOC0uNzUtLjYwMzQtMS42NTgyLS42MDM0LTIuNzI0MSAwLTEuMTk2MS4yNTU0LTIuMjM1Mi43NjY1LTMuMTE2Mi41MTA2LS44ODEgMS4xNzk0LTEuNjE1MSAyLjAwNjctMi4yMDIyLjgyNjMtLjU4NzIgMS43NjItMS4wMjI0IDIuODA2Mi0xLjMwNTEgMS4wNDM2LS4yODI2IDIuMDg3Ny0uNDI0IDMuMTMxOC0uNDI0IDEuMjE3OSAwIDIuMzg2Ni4xMzYzIDMuNTA3My40MDc4IDEuMTIwMS4yNzIxIDIuMTE1MS43MTI5IDIuOTg1NSAxLjMyMTMuODcwMy42MDg5IDEuNTYwOSAxLjQ4NDkgMi4wNzIgMi40MzEyLjUxMDYuOTQ2NC43NjY1IDIuNDIzNS43NjY1IDMuNTQwOGgtNC45NTkyYy0uMDQzNi0uNTU4Ni0uMTkxMS0xLjM3MDktLjQ0MDItMS44MjczLS4yNTAzLS40NTctLjU4MjctLjg2NDktLjk5NTYtMS4xMjU3LS40MTI4LS4yNjA5LS44ODY2LS40NzA0LTEuNDE5LS41Nzk0LS41MzM1LS4xMDg0LTEuMTE1LS4xNzU0LTEuNzQ1OC0uMTc1NC0uNDEzNCAwLS44MjYyLjAzOC0xLjIzOTYuMTI0Ni0uNDEzNC4wODcxLS43ODg5LjIzNjMtMS4xMjYzLjQ1MzYtLjMzNzQuMjE3OS0uNjE1MS40ODc3LS44MzE4LjgxNDUtLjIxNzkuMzI2My0uMzI2My43Mzg2LS4zMjYzIDEuMjM5MiAwIC40NTY0LjA4NjYuODI2Mi4yNjE1IDEuMTA4OS4xNzM3LjI4MjcuNTE2Mi41NDM1IDEuMDI3OS43ODI1LjUxMDYuMjQgMS4yMTc5LjQ3OSAyLjEyMDcuNzE4LjkwMjIuMjQgMi4wODI2LjU0NCAzLjU0MDIuOTEzLjQzNDYuMDg4IDEuMDM4LjI0NSAxLjgxMDYuNDc0Ljc3MjEuMjI4IDEuNTM4Ni41OTIgMi4zIDEuMDkzLjc2MTUuNSAxLjQxOTYgMS4xNyAxLjk3NDMgMi4wMDcuNTU0Mi44MzguODMxOCAxLjkwOS44MzE4IDMuMjE0LjAwMTIgMS4wNjQtLjIwNjEgMi4wNTQtLjYxODkgMi45NjdabTE0LjQ4NiAzLjk5OWgtNS43NzQ5TDc3LjMxIDkyLjg0OTFoNS4yNTMxbDUuMTg3NyAxNi4yMDA5aC4wNjUzbDUuMjUzMS0xNi4yMDA5aDUuMjg1NWwtNy43MzI0IDIyLjkwNDlabTMwLjMyNzcgMGgtMy4yNDdsLS41MjEtMi42NjhjLS45MTQgMS4xNzUtMS45MjYgMS45ODMtMy4wMzUgMi40NTEtMS4xMS40NjctMi4yMy42OTUtMy4zNjEuNjk1LTEuNzg0IDAtMy4zODgtLjMxNC00LjgxMy0uOTMzLTEuNDI1LS42Mi0yLjYyNi0xLjQ3NS0zLjYwNS0yLjU2My0uOTc5LTEuMDg3LTEuNzMtMi4zNjctMi4yNTEtMy44MzUtLjUyMjYtMS40NjgtLjc4My0zLjA1MS0uNzgzLTQuNzQ4IDAtMS43NC4yNjA5LTMuMzU1Ljc4My00Ljg0NTMuNTIyLTEuNDg5OSAxLjI3Mi0yLjc4OTkgMi4yNTEtMy44OTk0Ljk3OS0xLjEwOTUgMi4xOC0xLjk3OTQgMy42MDUtMi42MTAxIDEuNDI1LS42MzA3IDMuMDI5LS45NDU4IDQuODEzLS45NDU4IDEuMTk2IDAgMi4zNTQuMTc5MyAzLjQ3NS41MzggMS4xMi4zNTkyIDIuMTMxLjg4NjYgMy4wMzQgMS41ODIxLjkwMy42OTY3IDEuNjQ4IDEuNTczOCAyLjIzNiAyLjU5NjcuNTg3IDEuMDIyOS45NDUgMi40MjUxIDEuMDc2IDMuNTQyOGgtNC44OTRjLS4zMDUtMS4xMTc3LS44OTItMi4zMDMyLTEuNzYyLTIuOTU1Ny0uODctLjY1MjUtMS45MjUtLjk4ODItMy4xNjQtLjk4ODItMS4xNTQgMC0yLjEzMi4yMTktMi45MzcuNjY0Mi0uODA1LjQ0NjQtMS40NTggMS4wNDE5LTEuOTU4IDEuNzkyMnMtLjg2NSAxLjYwMzUtMS4wOTMgMi41NjA1Yy0uMjI5Ljk1Ny0uMzQzIDEuOTQ2LS4zNDMgMi45NjggMCAuOTc5LjExNCAxLjkzMS4zNDMgMi44NTUuMjI4LjkyNS41OTMgMS43NTcgMS4wOTMgMi40OTYuNS43MzkgMS4xNTIgMS4zMzIgMS45NTggMS43NzguODA0LjQ0NiAxLjc4My42NjkgMi45MzcuNjY5IDEuNjk2IDAgMy4wMDYtLjIwNiAzLjkzMS0xLjA2NS45MjQtLjg1OSAxLjQ2Mi0yLjM5NSAxLjYxNS0zLjUxMmgtNS40Mzl2LTMuOTExaDEwLjA1NnYxMi4yOTFaIi8+PC9nPjxkZWZzPjxjbGlwUGF0aCBpZD0iYSI+PHBhdGggZmlsbD0iI2ZmZiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNTAgMTkuNjY0OCkiIGQ9Ik0wIDBoMTAwdjEyMC42N0gweiIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg==", "description": "", "descriptor": { "type": "rpc", diff --git a/application/src/main/data/upgrade/3.7.0/schema_update.sql b/application/src/main/data/upgrade/3.7.0/schema_update.sql index d400821fa3..05f7423003 100644 --- a/application/src/main/data/upgrade/3.7.0/schema_update.sql +++ b/application/src/main/data/upgrade/3.7.0/schema_update.sql @@ -195,4 +195,21 @@ $$ END $$; --- OAUTH2 UPDATE END \ No newline at end of file +-- 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 diff --git a/application/src/main/java/org/thingsboard/server/controller/AdminController.java b/application/src/main/java/org/thingsboard/server/controller/AdminController.java index e4e6418168..a2115360d8 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AdminController.java +++ b/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.dao.audit.AuditLogService; 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.service.security.auth.jwt.settings.JwtSettingsService; import org.thingsboard.server.service.security.auth.oauth2.CookieUtils; @@ -109,6 +110,7 @@ public class AdminController extends BaseController { private final SmsService smsService; private final AdminSettingsService adminSettingsService; private final SystemSecurityService systemSecurityService; + private final SecuritySettingsService securitySettingsService; private final JwtSettingsService jwtSettingsService; private final JwtTokenFactory tokenFactory; private final EntitiesVersionControlService versionControlService; @@ -167,7 +169,7 @@ public class AdminController extends BaseController { @ResponseBody public SecuritySettings getSecuritySettings() throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); - return checkNotNull(systemSecurityService.getSecuritySettings()); + return checkNotNull(securitySettingsService.getSecuritySettings()); } @ApiOperation(value = "Update Security Settings (saveSecuritySettings)", @@ -179,7 +181,7 @@ public class AdminController extends BaseController { @Parameter(description = "A JSON value representing the Security Settings.") @RequestBody SecuritySettings securitySettings) throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.WRITE); - securitySettings = checkNotNull(systemSecurityService.saveSecuritySettings(securitySettings)); + securitySettings = checkNotNull(securitySettingsService.saveSecuritySettings(securitySettings)); return securitySettings; } diff --git a/application/src/main/java/org/thingsboard/server/controller/AuthController.java b/application/src/main/java/org/thingsboard/server/controller/AuthController.java index f95dfcb648..17f7930eff 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AuthController.java @@ -21,17 +21,15 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; 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.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; 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.thingsboard.rule.engine.api.MailService; 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.UserPasswordPolicy; 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.service.security.auth.rest.RestAuthenticationDetails; 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.system.SystemSecurityService; -import java.net.URI; -import java.net.URISyntaxException; - @RestController @TbCoreComponent @RequestMapping("/api") @@ -75,6 +71,7 @@ public class AuthController extends BaseController { private final JwtTokenFactory tokenFactory; private final MailService mailService; private final SystemSecurityService systemSecurityService; + private final SecuritySettingsService securitySettingsService; private final RateLimitService rateLimitService; private final ApplicationEventPublisher eventPublisher; @@ -82,9 +79,8 @@ public class AuthController extends BaseController { @ApiOperation(value = "Get current User (getUser)", 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')") - @RequestMapping(value = "/auth/user", method = RequestMethod.GET) - public @ResponseBody - User getUser() throws ThingsboardException { + @GetMapping(value = "/auth/user") + public User getUser() throws ThingsboardException { SecurityUser securityUser = getCurrentUser(); return userService.findUserById(securityUser.getTenantId(), securityUser.getId()); } @@ -92,8 +88,7 @@ public class AuthController extends BaseController { @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. ") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/auth/logout", method = RequestMethod.POST) - @ResponseStatus(value = HttpStatus.OK) + @PostMapping(value = "/auth/logout") public void logout(HttpServletRequest request) throws ThingsboardException { logLogoutAction(request); } @@ -101,8 +96,7 @@ public class AuthController extends BaseController { @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.") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/auth/changePassword", method = RequestMethod.POST) - @ResponseStatus(value = HttpStatus.OK) + @PostMapping(value = "/auth/changePassword") public JwtPair changePassword(@Parameter(description = "Change Password Request") @RequestBody ChangePasswordRequest changePasswordRequest) throws ThingsboardException { String currentPassword = changePasswordRequest.getCurrentPassword(); @@ -125,46 +119,34 @@ public class AuthController extends BaseController { @ApiOperation(value = "Get the current User password policy (getUserPasswordPolicy)", notes = "API call to get the password policy for the password validation form(s).") - @RequestMapping(value = "/noauth/userPasswordPolicy", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/noauth/userPasswordPolicy") public UserPasswordPolicy getUserPasswordPolicy() throws ThingsboardException { - SecuritySettings securitySettings = - checkNotNull(systemSecurityService.getSecuritySettings()); + SecuritySettings securitySettings = checkNotNull(securitySettingsService.getSecuritySettings()); return securitySettings.getPasswordPolicy(); } @ApiOperation(value = "Check Activate User Token (checkActivateToken)", 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 not valid, returns '409 Conflict'.") - @RequestMapping(value = "/noauth/activate", params = {"activateToken"}, method = RequestMethod.GET) - public ResponseEntity<String> checkActivateToken( + "If token is not valid, returns '409 Conflict'. " + + "If token is expired, redirects to error page.") + @GetMapping(value = "/noauth/activate", params = {"activateToken"}) + public ResponseEntity<?> checkActivateToken( @Parameter(description = "The activate token string.") @RequestParam(value = "activateToken") String activateToken) { - HttpHeaders headers = new HttpHeaders(); - HttpStatus responseStatus; UserCredentials userCredentials = userService.findUserCredentialsByActivateToken(TenantId.SYS_TENANT_ID, activateToken); - if (userCredentials != null) { - String createURI = "/login/createPassword"; - try { - URI location = new URI(createURI + "?activateToken=" + activateToken); - 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; + if (userCredentials == null) { + return response(HttpStatus.CONFLICT); + } else if (userCredentials.isActivationTokenExpired()) { + return redirectTo("/activationLinkExpired"); } - return new ResponseEntity<>(headers, responseStatus); + return redirectTo("/login/createPassword?activateToken=" + activateToken); } @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. " + "Always return '200 OK' status for security purposes.") - @RequestMapping(value = "/noauth/resetPasswordByEmail", method = RequestMethod.POST) - @ResponseStatus(value = HttpStatus.OK) + @PostMapping(value = "/noauth/resetPasswordByEmail") public void requestResetPasswordByEmail( @Parameter(description = "The JSON object representing the reset password email request.") @RequestBody ResetPasswordEmailRequest resetPasswordByEmailRequest, @@ -177,7 +159,7 @@ public class AuthController extends BaseController { String resetUrl = String.format("%s/api/noauth/resetPassword?resetToken=%s", baseUrl, userCredentials.getResetToken()); - mailService.sendResetPasswordEmailAsync(resetUrl, email); + mailService.sendResetPasswordEmailAsync(resetUrl, userCredentials.getResetTokenTtl(), email); } catch (Exception e) { log.warn("Error occurred: {}", e.getMessage()); } @@ -186,32 +168,22 @@ public class AuthController extends BaseController { @ApiOperation(value = "Check password reset token (checkResetToken)", 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 not valid, returns '409 Conflict'.") - @RequestMapping(value = "/noauth/resetPassword", params = {"resetToken"}, method = RequestMethod.GET) - public ResponseEntity<String> checkResetToken( + "If token is not valid, returns '409 Conflict'. " + + "If token is expired, redirects to error page.") + @GetMapping(value = "/noauth/resetPassword", params = {"resetToken"}) + public ResponseEntity<?> checkResetToken( @Parameter(description = "The reset token string.") @RequestParam(value = "resetToken") String resetToken) { - HttpHeaders headers = new HttpHeaders(); - HttpStatus responseStatus; - String resetURI = "/login/resetPassword"; UserCredentials userCredentials = userService.findUserCredentialsByResetToken(TenantId.SYS_TENANT_ID, resetToken); - - if (userCredentials != null) { - if (!rateLimitService.checkRateLimit(LimitedApi.PASSWORD_RESET, userCredentials.getUserId(), defaultLimitsConfiguration)) { - return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build(); - } - try { - URI location = new URI(resetURI + "?resetToken=" + resetToken); - 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; + if (userCredentials == null) { + return response(HttpStatus.CONFLICT); + } else if (userCredentials.isResetTokenExpired()) { + return redirectTo("/passwordResetLinkExpired"); + } + if (!rateLimitService.checkRateLimit(LimitedApi.PASSWORD_RESET, userCredentials.getUserId(), defaultLimitsConfiguration)) { + return response(HttpStatus.TOO_MANY_REQUESTS); } - return new ResponseEntity<>(headers, responseStatus); + return redirectTo("/login/resetPassword?resetToken=" + resetToken); } @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, " + "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 not valid, returns '404 Bad Request'.") - @RequestMapping(value = "/noauth/activate", method = RequestMethod.POST) - @ResponseStatus(value = HttpStatus.OK) - @ResponseBody - public JwtPair activateUser( - @Parameter(description = "Activate user request.") - @RequestBody ActivateUserRequest activateRequest, - @RequestParam(required = false, defaultValue = "true") boolean sendActivationMail, - HttpServletRequest request) throws ThingsboardException { + "If token is not valid, returns '400 Bad Request'.") + @PostMapping(value = "/noauth/activate") + public JwtPair activateUser(@Parameter(description = "Activate user request.") + @RequestBody ActivateUserRequest activateRequest, + @RequestParam(required = false, defaultValue = "true") boolean sendActivationMail, + HttpServletRequest request) { String activateToken = activateRequest.getActivateToken(); String password = activateRequest.getPassword(); systemSecurityService.validatePassword(password, null); @@ -258,18 +227,18 @@ public class AuthController extends BaseController { @ApiOperation(value = "Reset password (resetPassword)", 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 not valid, returns '404 Bad Request'.") - @RequestMapping(value = "/noauth/resetPassword", method = RequestMethod.POST) - @ResponseStatus(value = HttpStatus.OK) - @ResponseBody - public JwtPair resetPassword( - @Parameter(description = "Reset password request.") - @RequestBody ResetPasswordRequest resetPasswordRequest, - HttpServletRequest request) throws ThingsboardException { + "If token is not valid, returns '400 Bad Request'.") + @PostMapping(value = "/noauth/resetPassword") + public JwtPair resetPassword(@Parameter(description = "Reset password request.") + @RequestBody ResetPasswordRequest resetPasswordRequest, + HttpServletRequest request) throws ThingsboardException { String resetToken = resetPasswordRequest.getResetToken(); String password = resetPasswordRequest.getPassword(); UserCredentials userCredentials = userService.findUserCredentialsByResetToken(TenantId.SYS_TENANT_ID, resetToken); if (userCredentials != null) { + if (userCredentials.isResetTokenExpired()) { + throw new ThingsboardException("Password reset token expired", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } systemSecurityService.validatePassword(password, userCredentials); if (passwordEncoder.matches(password, userCredentials.getPassword())) { 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); userCredentials.setPassword(encodedPassword); userCredentials.setResetToken(null); + userCredentials.setResetTokenExpTime(null); userCredentials = userService.replaceUserCredentials(TenantId.SYS_TENANT_ID, userCredentials); User user = userService.findUserById(TenantId.SYS_TENANT_ID, userCredentials.getUserId()); UserPrincipal principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail()); diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 8021e71fd6..d266c7e6cc 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/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.Value; import org.springframework.dao.DataAccessException; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; 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.mobile.MobileAppService; 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.OAuth2ConfigTemplateService; import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.queue.QueueService; 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.TelemetrySubscriptionService; +import java.net.URI; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Objects; @@ -191,7 +193,7 @@ import static org.thingsboard.server.dao.service.Validator.validateId; @TbCoreComponent 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*/ @@ -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 { if (ids == null) { return Collections.emptyList(); diff --git a/application/src/main/java/org/thingsboard/server/controller/ImageController.java b/application/src/main/java/org/thingsboard/server/controller/ImageController.java index 6ebb6fffe8..88b33389a5 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ImageController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ImageController.java @@ -312,7 +312,7 @@ public class ImageController extends BaseController { if (StringUtils.isNotEmpty(etag)) { etag = StringUtils.remove(etag, '\"'); // etag is wrapped in double quotes due to HTTP specification if (etag.equals(tbImageService.getETag(cacheKey))) { - return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build(); + return response(HttpStatus.NOT_MODIFIED); } } diff --git a/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java b/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java index ca8b11ee80..11f017b85f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java +++ b/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java @@ -183,8 +183,7 @@ public class MobileApplicationController extends BaseController { .header("Location", appStoreLink) .build(); } else { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .build(); + return response(HttpStatus.NOT_FOUND); } } diff --git a/application/src/main/java/org/thingsboard/server/controller/UserController.java b/application/src/main/java/org/thingsboard/server/controller/UserController.java index 1772a64772..a71bd4dd38 100644 --- a/application/src/main/java/org/thingsboard/server/controller/UserController.java +++ b/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 jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; 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.server.common.data.EntityType; 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.alarm.Alarm; 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 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 ACTIVATE_URL_PATTERN = "%s/api/noauth/activate?activateToken=%s"; public static final String MOBILE_TOKEN_HEADER = "X-Mobile-Token"; @Value("${security.user_token_access_enabled}") @@ -130,12 +129,8 @@ public class UserController extends BaseController { private final SystemSecurityService systemSecurityService; private final ApplicationEventPublisher eventPublisher; private final TbUserService tbUserService; - - @Autowired - private EntityQueryService entityQueryService; - - @Autowired - private EntityService entityService; + private final EntityQueryService entityQueryService; + private final EntityService entityService; @ApiOperation(value = "Get User (getUserById)", notes = "Fetch the User object based on the provided User Id. " + @@ -212,7 +207,7 @@ public class UserController extends BaseController { public User saveUser( @Parameter(description = "A JSON value representing the User.", required = true) @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 { if (!Authority.SYS_ADMIN.equals(getCurrentUser().getAuthority())) { user.setTenantId(getCurrentUser().getTenantId()); @@ -230,45 +225,39 @@ public class UserController extends BaseController { @Parameter(description = "Email of the user", required = true) @RequestParam(value = "email") String email, HttpServletRequest request) throws ThingsboardException { - User user = checkNotNull(userService.findUserByEmail(getCurrentUser().getTenantId(), email)); - - accessControlService.checkPermission(getCurrentUser(), Resource.USER, Operation.READ, - user.getId(), user); + SecurityUser securityUser = getCurrentUser(); + User user = checkNotNull(userService.findUserByEmail(securityUser.getTenantId(), email)); + accessControlService.checkPermission(securityUser, Resource.USER, Operation.READ, user.getId(), user); - UserCredentials userCredentials = userService.findUserCredentialsByUserId(getCurrentUser().getTenantId(), user.getId()); - if (!userCredentials.isEnabled() && userCredentials.getActivateToken() != null) { - 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); - } + UserActivationLink activationLink = tbUserService.getActivationLink(securityUser.getTenantId(), securityUser.getCustomerId(), user.getId(), request); + mailService.sendActivationEmail(activationLink.value(), activationLink.ttlMs(), email); } - @ApiOperation(value = "Get the activation link (getActivationLink)", + @ApiOperation(value = "Get activation link (getActivationLink)", 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) @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 - public String getActivationLink( - @Parameter(description = USER_ID_PARAM_DESCRIPTION) - @PathVariable(USER_ID) String strUserId, - HttpServletRequest request) throws ThingsboardException { + public String getActivationLink(@Parameter(description = USER_ID_PARAM_DESCRIPTION) + @PathVariable(USER_ID) String strUserId, + 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); UserId userId = new UserId(toUUID(strUserId)); - User user = checkUserId(userId, Operation.READ); - SecurityUser authUser = getCurrentUser(); - UserCredentials userCredentials = userService.findUserCredentialsByUserId(authUser.getTenantId(), user.getId()); - 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); - } + checkUserId(userId, Operation.READ); + SecurityUser securityUser = getCurrentUser(); + return tbUserService.getActivationLink(securityUser.getTenantId(), securityUser.getCustomerId(), userId, request); } @ApiOperation(value = "Delete User (deleteUser)", @@ -411,7 +400,7 @@ public class UserController extends BaseController { public void setUserCredentialsEnabled( @Parameter(description = USER_ID_PARAM_DESCRIPTION) @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 { checkParameter(USER_ID, strUserId); UserId userId = new UserId(toUUID(strUserId)); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java index 840750a781..17ceb17ed1 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java +++ b/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.audit.ActionType; 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.EdgeEventType; +import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.RelationTypeGroup; @@ -134,6 +136,17 @@ public class EdgeEventSourcingListener { return; } 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); tbClusterService.sendNotificationMsgToEdge(event.getTenantId(), event.getEdgeId(), event.getEntityId(), event.getBody(), null, EdgeUtils.getEdgeEventActionTypeByActionType(event.getActionType()), diff --git a/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java index 851f59c3ab..2132b9e4a6 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java @@ -55,6 +55,7 @@ public class RelatedEdgesSourcingListener { @TransactionalEventListener(fallbackExecution = true) public void handleEvent(ActionEntityEvent<?> event) { executorService.submit(() -> { + log.trace("[{}] ActionEntityEvent called: {}", event.getTenantId(), event); try { switch (event.getActionType()) { case ASSIGNED_TO_EDGE, UNASSIGNED_FROM_EDGE -> @@ -69,6 +70,7 @@ public class RelatedEdgesSourcingListener { @TransactionalEventListener(fallbackExecution = true) public void handleEvent(DeleteEntityEvent<?> event) { executorService.submit(() -> { + log.trace("[{}] DeleteEntityEvent called: {}", event.getTenantId(), event); try { relatedEdgesService.publishRelatedEdgeIdsEvictEvent(event.getTenantId(), event.getEntityId()); } catch (Exception e) { diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java index 299d499daa..ad4b7c097e 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java +++ b/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.server.common.data.EntityType; 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.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; 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.security.system.SystemSecurityService; -import static org.thingsboard.server.controller.UserController.ACTIVATE_URL_PATTERN; +import java.util.concurrent.TimeUnit; @Service @TbCoreComponent @@ -53,13 +55,9 @@ public class DefaultUserService extends AbstractTbEntityService implements TbUse boolean sendEmail = tbUser.getId() == null && sendActivationMail; User savedUser = checkNotNull(userService.saveUser(tenantId, tbUser)); if (sendEmail) { - UserCredentials userCredentials = userService.findUserCredentialsByUserId(tenantId, savedUser.getId()); - String baseUrl = systemSecurityService.getBaseUrl(tenantId, customerId, request); - String activateUrl = String.format(ACTIVATE_URL_PATTERN, baseUrl, - userCredentials.getActivateToken()); - String email = savedUser.getEmail(); + UserActivationLink activationLink = getActivationLink(tenantId, customerId, savedUser.getId(), request); try { - mailService.sendActivationEmail(activateUrl, email); + mailService.sendActivationEmail(activationLink.value(), activationLink.ttlMs(), savedUser.getEmail()); } catch (ThingsboardException e) { userService.deleteUser(tenantId, savedUser); throw e; @@ -87,4 +85,24 @@ public class DefaultUserService extends AbstractTbEntityService implements TbUse 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); + } + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java index 388db9df40..e7689692a4 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java @@ -16,14 +16,19 @@ package org.thingsboard.server.service.entitiy.user; 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.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; public interface TbUserService { 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; + + UserActivationLink getActivationLink(TenantId tenantId, CustomerId customerId, UserId userId, HttpServletRequest request) throws ThingsboardException; + } diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java index 0085df35d7..1a03ae491a 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java +++ b/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); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java index 3577683729..1603720450 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java @@ -160,12 +160,12 @@ public class DefaultMailService implements MailService { } @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); Map<String, Object> model = new HashMap<>(); model.put("activationLink", activationLink); + model.put("activationLinkTtlInHours", (int) Math.ceil(ttlMs / 3600000.0)); model.put(TARGET_EMAIL, email); String message = mergeTemplateIntoString("activation.ftl", model); @@ -188,12 +188,13 @@ public class DefaultMailService implements MailService { } @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); Map<String, Object> model = new HashMap<>(); model.put("passwordResetLink", passwordResetLink); + model.put("passwordResetLinkTtlInHours", (int) Math.ceil(ttlMs / 3600000.0)); model.put(TARGET_EMAIL, email); String message = mergeTemplateIntoString("reset.password.ftl", model); @@ -202,10 +203,10 @@ public class DefaultMailService implements MailService { } @Override - public void sendResetPasswordEmailAsync(String passwordResetLink, String email) { + public void sendResetPasswordEmailAsync(String passwordResetLink, long ttlMs, String email) { passwordResetExecutorService.execute(() -> { try { - this.sendResetPasswordEmail(passwordResetLink, email); + this.sendResetPasswordEmail(passwordResetLink, ttlMs, email); } catch (Exception e) { log.error("Error occurred: {} ", e.getMessage()); } diff --git a/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java b/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java index 14e52c1326..7c86771f8d 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java @@ -32,7 +32,7 @@ public class DefaultTbMailConfigTemplateService implements TbMailConfigTemplateS @PostConstruct 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 diff --git a/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretServiceImpl.java index bef31622ff..73496ca1a0 100644 --- a/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretServiceImpl.java +++ b/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.security.model.JwtPair; 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.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 @Slf4j @@ -37,12 +38,12 @@ import static org.thingsboard.server.service.security.system.DefaultSystemSecuri public class MobileAppSecretServiceImpl extends AbstractCachedService<String, JwtPair, MobileSecretEvictEvent> implements MobileAppSecretService { private final JwtTokenFactory tokenFactory; - private final SystemSecurityService systemSecurityService; + private final SecuritySettingsService securitySettingsService; @Override public String generateMobileAppSecret(SecurityUser securityUser) { 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); cache.put(secret, tokenFactory.createTokenPair(securityUser)); return secret; @@ -63,4 +64,5 @@ public class MobileAppSecretServiceImpl extends AbstractCachedService<String, Jw public void handleEvictEvent(MobileSecretEvictEvent event) { cache.evict(event.getSecret()); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java index 65fa344957..b9fe54deec 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java +++ b/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.dao.customer.CustomerService; 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.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.MfaAuthenticationToken; @@ -58,6 +59,7 @@ import java.util.UUID; public class RestAuthenticationProvider implements AuthenticationProvider { private final SystemSecurityService systemSecurityService; + private final SecuritySettingsService securitySettingsService; private final UserService userService; private final CustomerService customerService; private final TwoFactorAuthService twoFactorAuthService; @@ -66,10 +68,12 @@ public class RestAuthenticationProvider implements AuthenticationProvider { public RestAuthenticationProvider(final UserService userService, final CustomerService customerService, final SystemSecurityService systemSecurityService, + SecuritySettingsService securitySettingsService, TwoFactorAuthService twoFactorAuthService) { this.userService = userService; this.customerService = customerService; this.systemSecurityService = systemSecurityService; + this.securitySettingsService = securitySettingsService; this.twoFactorAuthService = twoFactorAuthService; } @@ -82,13 +86,13 @@ public class RestAuthenticationProvider implements AuthenticationProvider { throw new BadCredentialsException("Authentication Failed. Bad user principal."); } - UserPrincipal userPrincipal = (UserPrincipal) principal; + UserPrincipal userPrincipal = (UserPrincipal) principal; SecurityUser securityUser; if (userPrincipal.getType() == UserPrincipal.Type.USER_NAME) { String username = userPrincipal.getValue(); String password = (String) authentication.getCredentials(); - SecuritySettings securitySettings = systemSecurityService.getSecuritySettings(); + SecuritySettings securitySettings = securitySettingsService.getSecuritySettings(); UserPasswordPolicy passwordPolicy = securitySettings.getPasswordPolicy(); if (Boolean.TRUE.equals(passwordPolicy.getForceUserToResetPasswordIfNotValid())) { try { diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java index 1b8cec2c91..6516bd7ce6 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java @@ -18,8 +18,8 @@ package org.thingsboard.server.service.security.system; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import jakarta.annotation.Resource; import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.passay.CharacterRule; import org.passay.EnglishCharacterData; @@ -29,9 +29,6 @@ import org.passay.PasswordValidator; import org.passay.Rule; import org.passay.RuleResult; 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.DisabledException; 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.exception.DataValidationException; 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.UserServiceImpl; import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; @@ -68,76 +66,23 @@ import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; -import static org.thingsboard.server.common.data.CacheConstants.SECURITY_SETTINGS_CACHE; - @Service @Slf4j +@RequiredArgsConstructor public class DefaultSystemSecurityService implements SystemSecurityService { - public static final int DEFAULT_MOBILE_SECRET_KEY_LENGTH = 64; - - @Autowired - private AdminSettingsService adminSettingsService; - - @Autowired - 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); - } - } + private final AdminSettingsService adminSettingsService; + private final BCryptPasswordEncoder encoder; + private final UserService userService; + private final MailService mailService; + private final AuditLogService auditLogService; + private final SecuritySettingsService securitySettingsService; @Override public void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String username, String password) throws AuthenticationException { if (!encoder.matches(password, userCredentials.getPassword())) { int failedLoginAttempts = userService.increaseFailedLoginAttempts(tenantId, userCredentials.getUserId()); - SecuritySettings securitySettings = self.getSecuritySettings(); + SecuritySettings securitySettings = securitySettingsService.getSecuritySettings(); if (securitySettings.getMaxFailedLoginAttempts() != null && securitySettings.getMaxFailedLoginAttempts() > 0) { if (failedLoginAttempts > securitySettings.getMaxFailedLoginAttempts() && userCredentials.isEnabled()) { lockAccount(userCredentials.getUserId(), username, securitySettings.getUserLockoutNotificationEmail(), securitySettings.getMaxFailedLoginAttempts()); @@ -153,7 +98,7 @@ public class DefaultSystemSecurityService implements SystemSecurityService { userService.resetFailedLoginAttempts(tenantId, userCredentials.getUserId()); - SecuritySettings securitySettings = self.getSecuritySettings(); + SecuritySettings securitySettings = securitySettingsService.getSecuritySettings(); if (isPositiveInteger(securitySettings.getPasswordPolicy().getPasswordExpirationPeriodDays())) { if ((userCredentials.getCreatedTime() + TimeUnit.DAYS.toMillis(securitySettings.getPasswordPolicy().getPasswordExpirationPeriodDays())) @@ -181,7 +126,7 @@ public class DefaultSystemSecurityService implements SystemSecurityService { if (maxVerificationFailures != null && maxVerificationFailures > 0 && failedVerificationAttempts >= maxVerificationFailures) { userService.setUserCredentialsEnabled(TenantId.SYS_TENANT_ID, userId, false); - SecuritySettings securitySettings = self.getSecuritySettings(); + SecuritySettings securitySettings = securitySettingsService.getSecuritySettings(); lockAccount(userId, securityUser.getEmail(), securitySettings.getUserLockoutNotificationEmail(), maxVerificationFailures); throw new LockedException("User account was locked due to exceeded 2FA verification attempts"); } @@ -200,7 +145,7 @@ public class DefaultSystemSecurityService implements SystemSecurityService { @Override public void validatePassword(String password, UserCredentials userCredentials) throws DataValidationException { - SecuritySettings securitySettings = self.getSecuritySettings(); + SecuritySettings securitySettings = securitySettingsService.getSecuritySettings(); UserPasswordPolicy passwordPolicy = securitySettings.getPasswordPolicy(); validatePasswordByPolicy(password, passwordPolicy); @@ -330,4 +275,5 @@ public class DefaultSystemSecurityService implements SystemSecurityService { private static boolean isPositiveInteger(Integer val) { return val != null && val.intValue() > 0; } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java index 737bf95b13..8c1ac733ee 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java +++ b/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.TenantId; 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.mfa.PlatformTwoFaSettings; import org.thingsboard.server.dao.exception.DataValidationException; @@ -30,10 +29,6 @@ import org.thingsboard.server.service.security.model.SecurityUser; public interface SystemSecurityService { - SecuritySettings getSecuritySettings(); - - SecuritySettings saveSecuritySettings(SecuritySettings securitySettings); - void validatePasswordByPolicy(String password, UserPasswordPolicy passwordPolicy); 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, String provider, Exception e); + } diff --git a/application/src/main/resources/templates/activation.ftl b/application/src/main/resources/templates/activation.ftl index ce51930521..d5c148a56b 100644 --- a/application/src/main/resources/templates/activation.ftl +++ b/application/src/main/resources/templates/activation.ftl @@ -88,7 +88,7 @@ background-color: #f6f6f6; </tr> <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"> - 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> </tr> <tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"> diff --git a/application/src/main/resources/templates/reset.password.ftl b/application/src/main/resources/templates/reset.password.ftl index 3f36c922f0..6b2fa5e8bf 100644 --- a/application/src/main/resources/templates/reset.password.ftl +++ b/application/src/main/resources/templates/reset.password.ftl @@ -93,7 +93,7 @@ background-color: #f6f6f6; </tr> <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"> - 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> </tr> <tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"> diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index eba04e7b5e..af20c71e5b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/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.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; @@ -340,7 +341,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { currentActivateToken = activationLink.split("=")[1]; return null; } - }).when(mailService).sendActivationEmail(anyString(), anyString()); + }).when(mailService).sendActivationEmail(anyString(), anyLong(), anyString()); Mockito.doAnswer(new Answer<Void>() { public Void answer(InvocationOnMock invocation) { @@ -349,7 +350,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { currentResetPasswordToken = passwordResetLink.split("=")[1]; return null; } - }).when(mailService).sendResetPasswordEmailAsync(anyString(), anyString()); + }).when(mailService).sendResetPasswordEmailAsync(anyString(), anyLong(), anyString()); } @After diff --git a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java index 1cf389a93d..817ded33e7 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java @@ -16,20 +16,30 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.databind.JsonNode; +import org.assertj.core.data.Offset; import org.junit.After; import org.junit.Test; import org.mockito.Mockito; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.http.HttpHeaders; import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; 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.UserCredentials; import org.thingsboard.server.common.data.security.model.SecuritySettings; 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.model.ChangePasswordRequest; 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.mockito.ArgumentMatchers.anyString; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; @@ -39,55 +49,55 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @DaoSqlTest public class AuthControllerTest extends AbstractControllerTest { + @SpyBean + private UserCredentialsDao userCredentialsDao; + @After public void tearDown() throws Exception { loginSysAdmin(); - SecuritySettings securitySettings = doGet("/api/admin/securitySettings", SecuritySettings.class); - - securitySettings.getPasswordPolicy().setMaximumLength(72); - securitySettings.getPasswordPolicy().setForceUserToResetPasswordIfNotValid(false); - - doPost("/api/admin/securitySettings", securitySettings).andExpect(status().isOk()); + updateSecuritySettings(securitySettings -> { + securitySettings.getPasswordPolicy().setMaximumLength(72); + securitySettings.getPasswordPolicy().setForceUserToResetPasswordIfNotValid(false); + }); } @Test public void testGetUser() throws Exception { - doGet("/api/auth/user") - .andExpect(status().isUnauthorized()); - + .andExpect(status().isUnauthorized()); + loginSysAdmin(); doGet("/api/auth/user") - .andExpect(status().isOk()) - .andExpect(jsonPath("$.authority",is(Authority.SYS_ADMIN.name()))) - .andExpect(jsonPath("$.email",is(SYS_ADMIN_EMAIL))); - + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authority", is(Authority.SYS_ADMIN.name()))) + .andExpect(jsonPath("$.email", is(SYS_ADMIN_EMAIL))); + loginTenantAdmin(); doGet("/api/auth/user") - .andExpect(status().isOk()) - .andExpect(jsonPath("$.authority",is(Authority.TENANT_ADMIN.name()))) - .andExpect(jsonPath("$.email",is(TENANT_ADMIN_EMAIL))); - + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authority", is(Authority.TENANT_ADMIN.name()))) + .andExpect(jsonPath("$.email", is(TENANT_ADMIN_EMAIL))); + loginCustomerUser(); doGet("/api/auth/user") - .andExpect(status().isOk()) - .andExpect(jsonPath("$.authority",is(Authority.CUSTOMER_USER.name()))) - .andExpect(jsonPath("$.email",is(CUSTOMER_USER_EMAIL))); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authority", is(Authority.CUSTOMER_USER.name()))) + .andExpect(jsonPath("$.email", is(CUSTOMER_USER_EMAIL))); } - + @Test public void testLoginLogout() throws Exception { loginSysAdmin(); doGet("/api/auth/user") - .andExpect(status().isOk()) - .andExpect(jsonPath("$.authority",is(Authority.SYS_ADMIN.name()))) - .andExpect(jsonPath("$.email",is(SYS_ADMIN_EMAIL))); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authority", is(Authority.SYS_ADMIN.name()))) + .andExpect(jsonPath("$.email", is(SYS_ADMIN_EMAIL))); TimeUnit.SECONDS.sleep(1); //We need to make sure that event for invalidating token was successfully processed logout(); doGet("/api/auth/user") - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()); resetTokens(); } @@ -97,14 +107,14 @@ public class AuthControllerTest extends AbstractControllerTest { loginSysAdmin(); doGet("/api/auth/user") .andExpect(status().isOk()) - .andExpect(jsonPath("$.authority",is(Authority.SYS_ADMIN.name()))) - .andExpect(jsonPath("$.email",is(SYS_ADMIN_EMAIL))); + .andExpect(jsonPath("$.authority", is(Authority.SYS_ADMIN.name()))) + .andExpect(jsonPath("$.email", is(SYS_ADMIN_EMAIL))); refreshToken(); doGet("/api/auth/user") .andExpect(status().isOk()) - .andExpect(jsonPath("$.authority",is(Authority.SYS_ADMIN.name()))) - .andExpect(jsonPath("$.email",is(SYS_ADMIN_EMAIL))); + .andExpect(jsonPath("$.authority", is(Authority.SYS_ADMIN.name()))) + .andExpect(jsonPath("$.email", is(SYS_ADMIN_EMAIL))); } @Test @@ -131,10 +141,10 @@ public class AuthControllerTest extends AbstractControllerTest { loginUser(TENANT_ADMIN_EMAIL, newPassword); loginSysAdmin(); - SecuritySettings securitySettings = doGet("/api/admin/securitySettings", SecuritySettings.class); - securitySettings.getPasswordPolicy().setMaximumLength(15); - securitySettings.getPasswordPolicy().setForceUserToResetPasswordIfNotValid(true); - doPost("/api/admin/securitySettings", securitySettings).andExpect(status().isOk()); + updateSecuritySettings(securitySettings -> { + securitySettings.getPasswordPolicy().setMaximumLength(15); + securitySettings.getPasswordPolicy().setForceUserToResetPasswordIfNotValid(true); + }); //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)) @@ -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."))); } + @Test public void testShouldNotResetPasswordToTooLongValue() throws Exception { loginTenantAdmin(); @@ -163,9 +174,95 @@ public class AuthControllerTest extends AbstractControllerTest { Mockito.doNothing().when(mailService).sendPasswordWasResetEmail(anyString(), anyString()); doPost("/api/noauth/resetPassword", resetPasswordRequest) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message", - is("Password must be no more than 72 characters in length."))); + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message", + 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 @@ -173,4 +270,19 @@ public class AuthControllerTest extends AbstractControllerTest { doGet("/login").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); + } + } diff --git a/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java index b3a1dd790e..5484c29305 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java +++ b/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.List; import java.util.Optional; +import java.util.Random; import java.util.TreeMap; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -124,6 +125,8 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { protected EdgeImitator edgeImitator; protected Edge edge; + private Random random = new Random(); + @Autowired protected EdgeEventService edgeEventService; @@ -163,15 +166,10 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { } private RuleChainId getEdgeRootRuleChainId() throws Exception { - List<RuleChain> edgeRuleChains = doGetTypedWithPageLink("/api/edge/" + edge.getUuidId() + "/ruleChains?", - new TypeReference<PageData<RuleChain>>() { - }, new PageLink(100)).getData(); - for (RuleChain edgeRuleChain : edgeRuleChains) { - if (edgeRuleChain.isRoot()) { - return edgeRuleChain.getId(); - } - } - throw new RuntimeException("Root rule chain not found"); + return doGetTypedWithPageLink("/api/ruleChains?type={type}&", new TypeReference<PageData<RuleChain>>() {}, + new PageLink(100, 0, "Edge Root Rule Chain"), + "EDGE") + .getData().get(0).getId(); } @After @@ -196,6 +194,8 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { Asset savedAsset = saveAsset("Edge Asset 1"); + updateRootRuleChainMetadata(); + edge = doPost("/api/edge", constructEdge("Test Edge", "test"), Edge.class); doPost("/api/edge/" + edge.getUuidId() @@ -207,6 +207,13 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { 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) { DeviceProfileData profileData = deviceProfile.getProfileData(); List<DeviceProfileAlarm> alarms = new ArrayList<>(); diff --git a/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java index b6311da80a..3b252046ab 100644 --- a/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java +++ b/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.dao.service.DaoSqlTest; 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.UplinkMsg; @@ -44,12 +45,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. public class DashboardEdgeTest extends AbstractEdgeTest { 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 public void testDashboards() throws Exception { // create dashboard and assign to edge - edgeImitator.expectMessageAmount(1); + edgeImitator.expectMessageAmount(2); Dashboard dashboard = new Dashboard(); dashboard.setTitle("Edge Test Dashboard"); dashboard.setMobileHide(true); @@ -59,23 +60,26 @@ public class DashboardEdgeTest extends AbstractEdgeTest { doPost("/api/edge/" + edge.getUuidId() + "/dashboard/" + savedDashboard.getUuidId(), Dashboard.class); Assert.assertTrue(edgeImitator.waitForMessages()); - AbstractMessage latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof DashboardUpdateMsg); - DashboardUpdateMsg dashboardUpdateMsg = (DashboardUpdateMsg) latestMessage; + Optional<DashboardUpdateMsg> dashboardUpdateMsgOpt = edgeImitator.findMessageByType(DashboardUpdateMsg.class); + Assert.assertTrue(dashboardUpdateMsgOpt.isPresent()); + DashboardUpdateMsg dashboardUpdateMsg = dashboardUpdateMsgOpt.get(); Dashboard dashboardMsg = JacksonUtil.fromString(dashboardUpdateMsg.getEntity(), Dashboard.class, true); Assert.assertNotNull(dashboardMsg); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, dashboardUpdateMsg.getMsgType()); 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()); testAutoGeneratedCodeByProtobuf(dashboardUpdateMsg); + Optional<ResourceUpdateMsg> resourceUpdateMsg = edgeImitator.findMessageByType(ResourceUpdateMsg.class); + Assert.assertTrue(resourceUpdateMsg.isPresent()); + // update dashboard edgeImitator.expectMessageAmount(1); savedDashboard.setTitle("Updated Edge Test Dashboard"); savedDashboard = doPost("/api/dashboard", savedDashboard, Dashboard.class); Assert.assertTrue(edgeImitator.waitForMessages()); - latestMessage = edgeImitator.getLatestMessage(); + AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof DashboardUpdateMsg); dashboardUpdateMsg = (DashboardUpdateMsg) latestMessage; dashboardMsg = JacksonUtil.fromString(dashboardUpdateMsg.getEntity(), Dashboard.class, true); diff --git a/application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java index c694df84f8..eaf4468156 100644 --- a/application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java +++ b/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); } + @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 public void testSetRootRuleChain() throws Exception { // create rule chain diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java index 42f635cd43..8f22812cfc 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java +++ b/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 generatePasswordResetToken(UserCredentials userCredentials); + + UserCredentials generateUserActivationToken(UserCredentials userCredentials); + UserCredentials replaceUserCredentials(TenantId tenantId, UserCredentials userCredentials); void deleteUser(TenantId tenantId, User user); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/UserActivationLink.java b/common/data/src/main/java/org/thingsboard/server/common/data/UserActivationLink.java new file mode 100644 index 0000000000..f532a9b066 --- /dev/null +++ b/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) { +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java index ed036e482a..1104ae2949 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java @@ -16,41 +16,31 @@ package org.thingsboard.server.common.data.security; import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; 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.UserId; -import org.thingsboard.server.common.data.validation.NoXss; -import static org.thingsboard.server.common.data.BaseDataWithAdditionalInfo.getJson; -import static org.thingsboard.server.common.data.BaseDataWithAdditionalInfo.setJson; +import java.io.Serial; +@Data @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 UserId userId; private boolean enabled; private String password; private String activateToken; + private Long activateTokenExpTime; 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() { super(); } @@ -59,75 +49,25 @@ public class UserCredentials extends BaseData<UserCredentialsId> { 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() { - return activateToken; + @JsonIgnore + public boolean isActivationTokenExpired() { + return getActivationTokenTtl() == 0; } - public void setActivateToken(String activateToken) { - this.activateToken = activateToken; - } - - public String getResetToken() { - return resetToken; + @JsonIgnore + public long getActivationTokenTtl() { + return activateTokenExpTime != null ? Math.max(activateTokenExpTime - System.currentTimeMillis(), 0) : 0; } - public void setResetToken(String resetToken) { - this.resetToken = resetToken; + @JsonIgnore + public boolean isResetTokenExpired() { + return getResetTokenTtl() == 0; } - @Override - public String toString() { - StringBuilder builder = new StringBuilder(); - 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(); + @JsonIgnore + public long getResetTokenTtl() { + return resetTokenExpTime != null ? Math.max(resetTokenExpTime - System.currentTimeMillis(), 0) : 0; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java index aa4d303440..70e1853ffd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java +++ b/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; 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 java.io.Serial; import java.io.Serializable; @Schema @Data public class SecuritySettings implements Serializable { + @Serial private static final long serialVersionUID = -1307613974597312465L; - @Schema(description = "The user password policy object." ) + @Schema(description = "The user password policy object.") 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; - @Schema(description = "Email to use for notifications about locked users." ) + + @Schema(description = "Email to use for notifications about locked users.") private String userLockoutNotificationEmail; - @Schema(description = "Mobile secret key length" ) + + @Schema(description = "Mobile secret key length") 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; + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/scheduler/DefaultSchedulerComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/scheduler/DefaultSchedulerComponent.java index a166259256..095e5b18dc 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/scheduler/DefaultSchedulerComponent.java +++ b/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.PreDestroy; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.thingsboard.common.util.ThingsBoardThreadFactory; @@ -26,14 +27,15 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +@Slf4j @Component public class DefaultSchedulerComponent implements SchedulerComponent { - protected ScheduledExecutorService schedulerExecutor; + private ScheduledExecutorService schedulerExecutor; @PostConstruct public void init() { - this.schedulerExecutor = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("queue-scheduler")); + schedulerExecutor = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("queue-scheduler")); } @PreDestroy @@ -52,10 +54,19 @@ public class DefaultSchedulerComponent implements SchedulerComponent { } 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) { - 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); + } + } + } diff --git a/common/queue/src/test/java/org/thingsboard/server/queue/scheduler/DefaultSchedulerComponentTest.java b/common/queue/src/test/java/org/thingsboard/server/queue/scheduler/DefaultSchedulerComponentTest.java new file mode 100644 index 0000000000..cff306c748 --- /dev/null +++ b/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(); + } + +} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/activity/AbstractActivityManager.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/activity/AbstractActivityManager.java index f754d6f5b1..ac535d28e1 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/activity/AbstractActivityManager.java +++ b/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."); 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 lastRecordedTime = new AtomicLong(); @@ -94,7 +94,7 @@ public abstract class AbstractActivityManager<Key, Metadata> implements Activity }); 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<>() { @Override public void onSuccess(Key key, long reportedTime) { @@ -103,7 +103,7 @@ public abstract class AbstractActivityManager<Key, Metadata> implements Activity @Override 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() { log.debug("Going to end reporting period."); for (Map.Entry<Key, ActivityStateWrapper> entry : states.entrySet()) { - var key = entry.getKey(); - var stateWrapper = entry.getValue(); - var currentState = stateWrapper.getState(); - - 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; + Key key = entry.getKey(); + ActivityStateWrapper stateWrapper = entry.getValue(); + try { + reportLastEvent(key, stateWrapper); + } catch (Exception e) { + log.error("Failed to report last activity event on reporting period end for key: [{}]. State: [{}].", key, stateWrapper, e); } + } + } - if (hasExpired) { - states.remove(key); - onStateExpiry(key, metadata); - shouldReport = true; - } + private void reportLastEvent(Key key, ActivityStateWrapper stateWrapper) { + var currentState = stateWrapper.getState(); + + 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) { - log.debug("Going to report last activity event for key: [{}].", key); - reportActivity(key, metadata, lastRecordedTime, new ActivityReportCallback<>() { - @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: [{}].", key, t); - } - }); - } + if (hasExpired) { + states.remove(key); + onStateExpiry(key, metadata); + shouldReport = true; + } + + if (shouldReport && lastReportedTime < lastRecordedTime) { + long timeToReport = lastRecordedTime; + log.debug("Going to report last activity event for key: [{}]. Event time: [{}].", key, timeToReport); + reportActivity(key, metadata, timeToReport, new ActivityReportCallback<>() { + @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); + } + }); } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/activity/strategy/FirstAndLastEventActivityStrategy.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/activity/strategy/FirstAndLastEventActivityStrategy.java index a5a59046ae..9ced9bd90b 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/activity/strategy/FirstAndLastEventActivityStrategy.java +++ b/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; import lombok.EqualsAndHashCode; +import lombok.ToString; +@ToString @EqualsAndHashCode public final class FirstAndLastEventActivityStrategy implements ActivityStrategy { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/activity/strategy/FirstEventActivityStrategy.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/activity/strategy/FirstEventActivityStrategy.java index 5353635c45..3b5d9dfc8d 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/activity/strategy/FirstEventActivityStrategy.java +++ b/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; import lombok.EqualsAndHashCode; +import lombok.ToString; +@ToString @EqualsAndHashCode public final class FirstEventActivityStrategy implements ActivityStrategy { diff --git a/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java b/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java index ff5716a361..02af9b53f8 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java +++ b/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.IOException; +import java.io.InputStream; import java.io.Reader; import java.io.Writer; 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() { return newObjectNode(OBJECT_MAPPER); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/edge/BaseRelatedEdgesService.java b/dao/src/main/java/org/thingsboard/server/dao/edge/BaseRelatedEdgesService.java index 265831640c..bedee455f4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/edge/BaseRelatedEdgesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/edge/BaseRelatedEdgesService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.edge; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; @@ -30,6 +31,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.entity.AbstractCachedEntityService; @Service +@Slf4j public class BaseRelatedEdgesService extends AbstractCachedEntityService<RelatedEdgesCacheKey, RelatedEdgesCacheValue, RelatedEdgesEvictEvent> implements RelatedEdgesService { public static final int RELATED_EDGES_CACHE_ITEMS = 1000; @@ -47,6 +49,7 @@ public class BaseRelatedEdgesService extends AbstractCachedEntityService<Related @Override 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)) { return edgeService.findEdgeIdsByTenantIdAndEntityId(tenantId, entityId, pageLink); } @@ -56,6 +59,7 @@ public class BaseRelatedEdgesService extends AbstractCachedEntityService<Related @Override public void publishRelatedEdgeIdsEvictEvent(TenantId tenantId, EntityId entityId) { + log.trace("Executing publishRelatedEdgeIdsEvictEvent, tenantId [{}], entityId [{}]", tenantId, entityId); publishEvictEvent(new RelatedEdgesEvictEvent(tenantId, entityId)); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 1fbab9c47c..b920160cc0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/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_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_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_EXP_TIME_PROPERTY = "reset_token_exp_time"; public static final String USER_CREDENTIALS_ADDITIONAL_PROPERTY = "additional_info"; /** diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserCredentialsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserCredentialsEntity.java index 59ba5ac4dd..edd1536a04 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserCredentialsEntity.java +++ b/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) 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) private String resetToken; + @Column(name = ModelConstants.USER_CREDENTIALS_RESET_TOKEN_EXP_TIME_PROPERTY) + private Long resetTokenExpTime; + @Convert(converter = JsonConverter.class) @Column(name = ModelConstants.USER_CREDENTIALS_ADDITIONAL_PROPERTY) private JsonNode additionalInfo; @@ -72,7 +78,9 @@ public final class UserCredentialsEntity extends BaseSqlEntity<UserCredentials> this.enabled = userCredentials.isEnabled(); this.password = userCredentials.getPassword(); this.activateToken = userCredentials.getActivateToken(); + this.activateTokenExpTime = userCredentials.getActivateTokenExpTime(); this.resetToken = userCredentials.getResetToken(); + this.resetTokenExpTime = userCredentials.getResetTokenExpTime(); this.additionalInfo = userCredentials.getAdditionalInfo(); } @@ -86,7 +94,9 @@ public final class UserCredentialsEntity extends BaseSqlEntity<UserCredentials> userCredentials.setEnabled(enabled); userCredentials.setPassword(password); userCredentials.setActivateToken(activateToken); + userCredentials.setActivateTokenExpTime(activateTokenExpTime); userCredentials.setResetToken(resetToken); + userCredentials.setResetTokenExpTime(resetTokenExpTime); userCredentials.setAdditionalInfo(additionalInfo); return userCredentials; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java b/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java index 902bf0f6a7..6f34437e0d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java +++ b/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); throw new RuntimeException(e); } - if (!ruleChainId.equals(edge.getRootRuleChainId())) { - eventPublisher.publishEvent(ActionEntityEvent.builder().tenantId(tenantId).edgeId(edgeId).entityId(ruleChainId) - .actionType(ActionType.ASSIGNED_TO_EDGE).build()); - } + eventPublisher.publishEvent(ActionEntityEvent.builder().tenantId(tenantId).edgeId(edgeId).entityId(ruleChainId) + .actionType(ActionType.ASSIGNED_TO_EDGE).body(JacksonUtil.toString(edge)).build()); return ruleChain; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/settings/DefaultSecuritySettingsService.java b/dao/src/main/java/org/thingsboard/server/dao/settings/DefaultSecuritySettingsService.java new file mode 100644 index 0000000000..18c2f68c0a --- /dev/null +++ b/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); + } + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/settings/SecuritySettingsService.java b/dao/src/main/java/org/thingsboard/server/dao/settings/SecuritySettingsService.java new file mode 100644 index 0000000000..8b9e3bfeee --- /dev/null +++ b/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); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java index f64ccf4970..ba0d3d69f3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java +++ b/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.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; +import org.thingsboard.server.dao.settings.SecuritySettingsService; import org.thingsboard.server.dao.sql.JpaExecutorService; import java.util.ArrayList; @@ -72,6 +73,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.TimeUnit; import static org.thingsboard.server.common.data.StringUtils.generateSafeToken; 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 UserSettingsService userSettingsService; private final UserSettingsDao userSettingsDao; + private final SecuritySettingsService securitySettingsService; private final DataValidator<User> userValidator; private final DataValidator<UserCredentials> userCredentialsValidator; private final ApplicationEventPublisher eventPublisher; @@ -178,9 +181,9 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U countService.publishCountEntityEvictEvent(savedUser.getTenantId(), EntityType.USER); UserCredentials userCredentials = new UserCredentials(); userCredentials.setEnabled(false); - userCredentials.setActivateToken(generateSafeToken(DEFAULT_TOKEN_LENGTH)); userCredentials.setUserId(new UserId(savedUser.getUuidId())); userCredentials.setAdditionalInfo(JacksonUtil.newObjectNode()); + userCredentials = generateUserActivationToken(userCredentials); userCredentialsDao.save(user.getTenantId(), userCredentials); } eventPublisher.publishEvent(SaveEntityEvent.builder() @@ -242,8 +245,12 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U if (userCredentials.isEnabled()) { throw new IncorrectParameterException("User credentials already activated"); } + if (userCredentials.isActivationTokenExpired()) { + throw new IncorrectParameterException("Activation token expired"); + } userCredentials.setEnabled(true); userCredentials.setActivateToken(null); + userCredentials.setActivateTokenExpTime(null); userCredentials.setPassword(password); if (userCredentials.getPassword() != null) { updatePasswordHistory(userCredentials); @@ -263,7 +270,7 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U if (!userCredentials.isEnabled()) { throw new DisabledException(String.format("User credentials not enabled [%s]", email)); } - userCredentials.setResetToken(generateSafeToken(DEFAULT_TOKEN_LENGTH)); + userCredentials = generatePasswordResetToken(userCredentials); return saveUserCredentials(tenantId, userCredentials); } @@ -273,10 +280,26 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U if (!userCredentials.isEnabled()) { throw new IncorrectParameterException("Unable to reset password for inactive user"); } - userCredentials.setResetToken(generateSafeToken(DEFAULT_TOKEN_LENGTH)); + userCredentials = generatePasswordResetToken(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 public UserCredentials replaceUserCredentials(TenantId tenantId, UserCredentials userCredentials) { log.trace("Executing replaceUserCredentials [{}]", userCredentials); diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 46e739e5d5..9c95f385f8 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/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, created_time bigint NOT NULL, activate_token varchar(255) UNIQUE, + activate_token_exp_time BIGINT, enabled boolean, password varchar(255), reset_token varchar(255) UNIQUE, + reset_token_exp_time BIGINT, user_id uuid UNIQUE, additional_info varchar DEFAULT '{}' ); diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/user/JpaUserCredentialsDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/user/JpaUserCredentialsDaoTest.java index a1e559414e..d68197c1bd 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/sql/user/JpaUserCredentialsDaoTest.java +++ b/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.setPassword("password"); userCredentials.setActivateToken("ACTIVATE_TOKEN_" + number); + userCredentials.setActivateTokenExpTime(123L); userCredentials.setResetToken("RESET_TOKEN_" + number); + userCredentials.setResetTokenExpTime(321L); return userCredentialsDao.save(SYSTEM_TENANT_ID, userCredentials); } diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MailService.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MailService.java index ecaf7ef3f9..043181a710 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MailService.java +++ b/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 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 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; diff --git a/ui-ngx/src/app/core/http/user.service.ts b/ui-ngx/src/app/core/http/user.service.ts index 1d5ce28998..695bae3cc1 100644 --- a/ui-ngx/src/app/core/http/user.service.ts +++ b/ui-ngx/src/app/core/http/user.service.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; 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 { HttpClient, HttpParams } from '@angular/common/http'; import { PageLink } from '@shared/models/page/page-link'; @@ -77,6 +77,10 @@ export class UserService { {...{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) { const encodeEmail = encodeURIComponent(email); return this.http.post(`/api/user/sendActivationMail?email=${encodeEmail}`, null, defaultHttpOptionsFromConfig(config)); diff --git a/ui-ngx/src/app/core/services/dynamic-component-factory.service.ts b/ui-ngx/src/app/core/services/dynamic-component-factory.service.ts index 8414e6ff0e..d18797a1be 100644 --- a/ui-ngx/src/app/core/services/dynamic-component-factory.service.ts +++ b/ui-ngx/src/app/core/services/dynamic-component-factory.service.ts @@ -22,7 +22,7 @@ import { NgModule, NgModuleRef, OnDestroy, - Type, + Type, ɵNG_COMP_DEF, ɵresetCompiledComponents } from '@angular/core'; import { from, Observable, of } from 'rxjs'; @@ -64,7 +64,6 @@ export class DynamicComponentFactoryService { template: string, modules?: Type<any>[], preserveWhitespaces?: boolean, - compileAttempt = 1, styles?: string[]): Observable<DynamicComponentData<T>> { return from(import('@angular/compiler')).pipe( mergeMap(() => { @@ -78,32 +77,26 @@ export class DynamicComponentFactoryService { declarations: [comp], imports: moduleImports })(class DynamicComponentInstanceModule extends DynamicComponentModule {}); + + const module = this.compiler.compileModuleSync(dynamicComponentInstanceModule); + let moduleRef: NgModuleRef<any>; try { - const module = this.compiler.compileModuleSync(dynamicComponentInstanceModule); - let moduleRef: NgModuleRef<any>; - try { - moduleRef = module.create(this.injector); - } catch (e) { - this.compiler.clearCacheFor(module.moduleType); - 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; - } + moduleRef = module.create(this.injector); + // eslint-disable-next-line + comp[ɵNG_COMP_DEF]; + } catch (e) { + this.compiler.clearCacheFor(module.moduleType); + ɵresetCompiledComponents(); + throw e; } + this.dynamicComponentModulesMap.set(comp, { + moduleRef, + moduleType: module.moduleType + }); + return of( { + componentType: comp, + componentModuleRef: moduleRef + }); }) ); } diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html index 44e288d2e0..c1f0d7e635 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html +++ b/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 }}"> </mat-form-field> </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> <tb-widget-actions-panel formControlName="actions"> diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.ts index 0bffd39ce4..5f14b7569e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.ts +++ b/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), []], borderRadius: [configData.config.borderRadius, []], + padding: [settings.padding, []], actions: [configData.config.actions || {}, []] }); @@ -183,6 +184,7 @@ export class ValueCardBasicConfigComponent extends BasicWidgetConfigComponent { this.setCardButtons(config.cardButtons, this.widgetConfig.config); this.widgetConfig.config.borderRadius = config.borderRadius; + this.widgetConfig.config.settings.padding = config.padding; this.widgetConfig.config.actions = config.actions; return this.widgetConfig; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.html index 52563a1b46..e43dd1665a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.html +++ b/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 }}"> </mat-form-field> </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> <tb-widget-actions-panel formControlName="actions"> diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.ts index d2110026f6..0e012cefe7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.ts +++ b/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), []], borderRadius: [configData.config.borderRadius, []], + padding: [settings.padding, []], actions: [configData.config.actions || {}, []] }); @@ -99,6 +100,7 @@ export class StatusWidgetBasicConfigComponent extends BasicWidgetConfigComponent this.setCardButtons(config.cardButtons, this.widgetConfig.config); this.widgetConfig.config.borderRadius = config.borderRadius; + this.widgetConfig.config.settings.padding = config.padding; this.widgetConfig.config.actions = config.actions; return this.widgetConfig; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.html index 7a638c70f3..9fa2d1b1a5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.html +++ b/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> </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 translate>widget-config.show-card-buttons</div> <mat-chip-listbox multiple formControlName="cardButtons"> diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.ts index 75c93bc852..dfdc9e8eb0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.ts +++ b/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), []], borderRadius: [configData.config.borderRadius, []], + padding: [settings.padding, []], actions: [configData.config.actions || {}, []] }); @@ -159,6 +160,7 @@ export class SingleSwitchBasicConfigComponent extends BasicWidgetConfigComponent this.setCardButtons(config.cardButtons, this.widgetConfig.config); this.widgetConfig.config.borderRadius = config.borderRadius; + this.widgetConfig.config.settings.padding = config.padding; this.widgetConfig.config.actions = config.actions; return this.widgetConfig; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.html index 7854a9f3b3..0dd9a2ebd9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.html +++ b/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 }}"> </mat-form-field> </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> <tb-widget-actions-panel formControlName="actions"> diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.ts index fca3bd2502..08e7003b65 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.ts +++ b/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), []], borderRadius: [configData.config.borderRadius, []], + padding: [settings.padding, []], actions: [configData.config.actions || {}, []] }); @@ -198,6 +199,7 @@ export class WindSpeedDirectionBasicConfigComponent extends BasicWidgetConfigCom this.setCardButtons(config.cardButtons, this.widgetConfig.config); this.widgetConfig.config.borderRadius = config.borderRadius; + this.widgetConfig.config.settings.padding = config.padding; this.widgetConfig.config.actions = config.actions; return this.widgetConfig; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html index 72598a4cdf..814f9f08d8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html @@ -15,41 +15,41 @@ 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-title-panel"> <ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container> </div> <div #valueCardContent class="tb-value-card-content" [class]="this.layout"> <ng-container [ngSwitch]="layout"> - <ng-template [ngSwitchCase]="valueCardLayout.square"> - <ng-container *ngTemplateOutlet="iconWithLabelTpl"></ng-container> - <ng-container *ngTemplateOutlet="valueTpl"></ng-container> - </ng-template> - <ng-template [ngSwitchCase]="valueCardLayout.vertical"> - <ng-container *ngTemplateOutlet="labelTpl"></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-template [ngSwitchCase]="valueCardLayout.square"> + <ng-container *ngTemplateOutlet="iconWithLabelTpl"></ng-container> + <ng-container *ngTemplateOutlet="valueTpl"></ng-container> + </ng-template> + <ng-template [ngSwitchCase]="valueCardLayout.vertical"> + <ng-container *ngTemplateOutlet="labelTpl"></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="valueTpl"></ng-container> + </div> + <ng-container *ngTemplateOutlet="dateTpl"></ng-container> + </ng-template> + <ng-template [ngSwitchCase]="valueCardLayout.simplified"> <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="labelTpl"></ng-container> - </ng-template> - <ng-template [ngSwitchCase]="layout === valueCardLayout.horizontal || - layout === valueCardLayout.horizontal_reversed ? layout : ''"> - <ng-container *ngTemplateOutlet="iconWithLabelTpl"></ng-container> - <div fxFlex></div> - <ng-container *ngTemplateOutlet="valueTpl"></ng-container> - </ng-template> - </ng-container> + <ng-container *ngTemplateOutlet="labelTpl"></ng-container> + </ng-template> + <ng-template [ngSwitchCase]="layout === valueCardLayout.horizontal || + layout === valueCardLayout.horizontal_reversed ? layout : ''"> + <ng-container *ngTemplateOutlet="iconWithLabelTpl"></ng-container> + <div fxFlex></div> + <ng-container *ngTemplateOutlet="valueTpl"></ng-container> + </ng-template> + </ng-container> </div> </div> <ng-template #iconWithLabelTpl> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts index ed361c1e76..96ec634362 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts @@ -38,6 +38,7 @@ import { getSingleTsValue, iconStyle, overlayStyle, + resolveCssSize, textStyle } from '@shared/models/widget-settings.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'; const squareLayoutSize = 160; -const squareLayoutPadding = 48; const horizontalLayoutHeight = 80; @Component({ @@ -96,6 +96,7 @@ export class ValueCardWidgetComponent implements OnInit, AfterViewInit, OnDestro backgroundStyle$: Observable<ComponentStyle>; overlayStyle: ComponentStyle = {}; + padding: string; 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.overlayStyle = overlayStyle(this.settings.background.overlay); + this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding; } public ngAfterViewInit() { @@ -199,8 +201,13 @@ export class ValueCardWidgetComponent implements OnInit, AfterViewInit, OnDestro } private onResize() { - const panelWidth = this.valueCardPanel.nativeElement.getBoundingClientRect().width - squareLayoutPadding; - const panelHeight = this.valueCardPanel.nativeElement.getBoundingClientRect().height - (this.horizontal ? 0 : squareLayoutPadding); + const computedStyle = getComputedStyle(this.valueCardPanel.nativeElement); + 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; if (!this.horizontal) { const size = Math.min(panelWidth, panelHeight); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.models.ts index df5fc78e9a..2ca299cb6e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.models.ts @@ -19,8 +19,10 @@ import { BackgroundType, ColorSettings, constantColor, - cssUnit, DateFormatSettings, - Font, lastUpdateAgoDateFormat + cssUnit, + DateFormatSettings, + Font, + lastUpdateAgoDateFormat } from '@shared/models/widget-settings.models'; export enum ValueCardLayout { @@ -80,6 +82,7 @@ export interface ValueCardWidgetSettings { dateFont: Font; dateColor: ColorSettings; background: BackgroundSettings; + padding: string; } export const valueCardDefaultSettings = (horizontal: boolean): ValueCardWidgetSettings => ({ @@ -128,5 +131,6 @@ export const valueCardDefaultSettings = (horizontal: boolean): ValueCardWidgetSe color: 'rgba(255,255,255,0.72)', blur: 3 } - } + }, + padding: '' }); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.html index 391ade6228..95eb4a0ba1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.html +++ b/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"> <ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container> </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"> <tb-icon [style]="iconStyle">{{ icon }}</tb-icon> </div> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.ts index 5b59b0210f..d8d5b48ff6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.ts @@ -21,14 +21,16 @@ import { ElementRef, OnDestroy, OnInit, - Renderer2, ViewChild, + Renderer2, + ViewChild, ViewEncapsulation } from '@angular/core'; import { BasicActionWidgetComponent } from '@home/components/widget/lib/action/action-widget.models'; import { statusWidgetDefaultSettings, StatusWidgetLayout, - StatusWidgetSettings, StatusWidgetStateSettings + StatusWidgetSettings, + StatusWidgetStateSettings } from '@home/components/widget/lib/indicator/status-widget.models'; import { Observable } from 'rxjs'; import { @@ -36,12 +38,12 @@ import { ComponentStyle, iconStyle, overlayStyle, + resolveCssSize, textStyle } from '@shared/models/widget-settings.models'; import { ResizeObserver } from '@juggle/resize-observer'; import { ImagePipe } from '@shared/pipe/image.pipe'; import { DomSanitizer } from '@angular/platform-browser'; -import { UtilsService } from '@core/services/utils.service'; import { ValueType } from '@shared/models/constants'; const initialStatusWidgetSize = 147; @@ -65,6 +67,7 @@ export class StatusWidgetComponent extends backgroundStyle$: Observable<ComponentStyle>; overlayStyle: ComponentStyle = {}; + padding: string; overlayInset = '12px'; borderRadius = ''; @@ -101,9 +104,7 @@ export class StatusWidgetComponent extends constructor(protected imagePipe: ImagePipe, protected sanitizer: DomSanitizer, private renderer: Renderer2, - private utils: UtilsService, - protected cd: ChangeDetectorRef, - private elementRef: ElementRef) { + protected cd: ChangeDetectorRef) { super(cd); } @@ -191,8 +192,13 @@ export class StatusWidgetComponent extends } private onResize() { - const panelWidth = this.statusWidgetPanel.nativeElement.getBoundingClientRect().width; - const panelHeight = this.statusWidgetPanel.nativeElement.getBoundingClientRect().height; + const computedStyle = getComputedStyle(this.statusWidgetPanel.nativeElement); + 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 scale = targetSize / initialStatusWidgetSize; const width = initialStatusWidgetSize; @@ -220,6 +226,9 @@ export class StatusWidgetComponent extends this.showLabel = stateSettings.showLabel && this.layout !== StatusWidgetLayout.icon; this.showStatus = stateSettings.showStatus && this.layout !== StatusWidgetLayout.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 secondaryColor = disabled ? stateSettings.secondaryColorDisabled : stateSettings.secondaryColor; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.models.ts index 2ef999c6f2..4dbea591f9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.models.ts @@ -65,6 +65,7 @@ export interface StatusWidgetSettings { layout: StatusWidgetLayout; onState: StatusWidgetStateSettings; offState: StatusWidgetStateSettings; + padding: string } export const statusWidgetDefaultSettings: StatusWidgetSettings = { @@ -200,5 +201,6 @@ export const statusWidgetDefaultSettings: StatusWidgetSettings = { blur: 3 } } - } + }, + padding: '' }; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html index fd9aaca4a6..a8dde3ed4c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html @@ -15,7 +15,7 @@ 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-title-panel"> <ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts index 2c15634893..63ed57628b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts @@ -36,6 +36,7 @@ import { ComponentStyle, iconStyle, overlayStyle, + resolveCssSize, textStyle } from '@shared/models/widget-settings.models'; import { Observable } from 'rxjs'; @@ -74,6 +75,7 @@ export class SingleSwitchWidgetComponent extends backgroundStyle$: Observable<ComponentStyle>; overlayStyle: ComponentStyle = {}; + padding: string; overlayInset = '12px'; value = false; @@ -123,6 +125,7 @@ export class SingleSwitchWidgetComponent extends this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); this.overlayStyle = overlayStyle(this.settings.background.overlay); + this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding; this.layout = this.settings.layout; @@ -231,11 +234,15 @@ export class SingleSwitchWidgetComponent extends } private onResize() { - const height = this.singleSwitchPanel.nativeElement.getBoundingClientRect().height; + const widgetBoundingClientRect = this.singleSwitchPanel.nativeElement.getBoundingClientRect(); + const height = widgetBoundingClientRect.height; const switchScale = height / initialSwitchHeight; const paddingScale = Math.min(switchScale, 1); - const panelWidth = this.singleSwitchPanel.nativeElement.getBoundingClientRect().width - (horizontalLayoutPadding * paddingScale); - const panelHeight = this.singleSwitchPanel.nativeElement.getBoundingClientRect().height - (verticalLayoutPadding * paddingScale); + const computedStyle = getComputedStyle(this.singleSwitchPanel.nativeElement); + 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, 'width', 'auto'); let contentWidth = this.singleSwitchToggleRow.nativeElement.getBoundingClientRect().width; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.models.ts index 94586c31de..62aae25816 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.models.ts @@ -80,6 +80,7 @@ export interface SingleSwitchWidgetSettings { offLabelFont: Font; offLabelColor: string; background: BackgroundSettings; + padding: string; } export const singleSwitchDefaultSettings: SingleSwitchWidgetSettings = { @@ -217,5 +218,6 @@ export const singleSwitchDefaultSettings: SingleSwitchWidgetSettings = { color: 'rgba(255,255,255,0.72)', blur: 3 } - } + }, + padding: '' }; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts index ff74bc58ac..263297ee75 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts @@ -191,10 +191,10 @@ export interface ScadaSymbolMetadata { properties: ScadaSymbolProperty[]; } -export const emptyMetadata = (): ScadaSymbolMetadata => ({ +export const emptyMetadata = (width?: number, height?: number): ScadaSymbolMetadata => ({ title: '', - widgetSizeX: 3, - widgetSizeY: 3, + widgetSizeX: width ? width/100 : 3, + widgetSizeY: height ? height/100 : 3, tags: [], behavior: [], properties: [] @@ -255,7 +255,17 @@ const parseScadaSymbolMetadataFromDom = (svgDoc: Document): ScadaSymbolMetadata if (elements.length) { return JSON.parse(elements[0].textContent); } 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) { console.error(_e); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.html index 76f46862a6..5077c93d8e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.html +++ b/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> </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> </ng-container> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.ts index 9693ca22d4..be7794802c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.ts +++ b/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, []], dateColor: [settings.dateColor, []], - background: [settings.background, []] + background: [settings.background, []], + padding: [settings.padding, []] }); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html index db6d0371f9..2709266f2f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html +++ b/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> </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 class="tb-form-panel"> <div class="tb-form-panel-title" translate>widgets.single-switch.switch</div> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.ts index a1a3e951f2..a4a76e5294 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.ts +++ b/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, []], offLabelColor: [settings.offLabelColor, []], - background: [settings.background, []] + background: [settings.background, []], + padding: [settings.padding, []] }); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.html index 21b7bc5cf0..892311733c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.html +++ b/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" formControlName="offState"> </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> </ng-container> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.ts index 03da985a0e..1130b58b21 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/status-widget-settings.component.ts +++ b/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, []], layout: [settings.layout, []], onState: [settings.onState, []], - offState: [settings.offState, []] + offState: [settings.offState, []], + padding: [settings.padding, []] }); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.html index 9265164c52..5271825ccf 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.html +++ b/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> </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> </ng-container> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.ts index e6b8880758..2d16a030b7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.ts +++ b/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, []], - background: [settings.background, []] + background: [settings.background, []], + padding: [settings.padding, []] }); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.html index 80ca1960f9..0ec08c2a05 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.html @@ -15,7 +15,7 @@ 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> <ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container> <div class="tb-wind-speed-direction-content"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.ts index 4ce5cf21d7..929f41fe48 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.ts @@ -42,7 +42,6 @@ import { getSingleTsValueByDataKey, overlayStyle } from '@shared/models/widget-settings.models'; -import { WidgetComponent } from '@home/components/widget/widget.component'; import { formatValue, isDefinedAndNotNull, isNumeric } from '@core/utils'; import { ResizeObserver } from '@juggle/resize-observer'; import { Path, Svg, SVG, Text } from '@svgdotjs/svg.js'; @@ -92,6 +91,7 @@ export class WindSpeedDirectionWidgetComponent implements OnInit, OnDestroy, Aft backgroundStyle$: Observable<ComponentStyle>; overlayStyle: ComponentStyle = {}; + padding: string; shapeResize$: ResizeObserver; @@ -111,8 +111,7 @@ export class WindSpeedDirectionWidgetComponent implements OnInit, OnDestroy, Aft private windDirection = 0; private centerValueText = 'N/A'; - constructor(private widgetComponent: WidgetComponent, - private imagePipe: ImagePipe, + constructor(private imagePipe: ImagePipe, private sanitizer: DomSanitizer, private renderer: Renderer2, 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.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; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.models.ts index 7801f80a17..5daa9e3d68 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.models.ts @@ -14,7 +14,6 @@ /// limitations under the License. /// -import { BatteryLevelLayout } from '@home/components/widget/lib/indicator/battery-level-widget.models'; import { BackgroundSettings, BackgroundType, @@ -59,6 +58,7 @@ export interface WindSpeedDirectionWidgetSettings { minorTicksColor: string; minorTicksFont: Font; background: BackgroundSettings; + padding: string } export const windSpeedDirectionDefaultSettings: WindSpeedDirectionWidgetSettings = { @@ -101,5 +101,6 @@ export const windSpeedDirectionDefaultSettings: WindSpeedDirectionWidgetSettings color: 'rgba(255,255,255,0.72)', blur: 3 } - } + }, + padding: '' }; diff --git a/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html index 3d399d01a1..0b9df7c5ae 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html @@ -46,6 +46,44 @@ <input matInput type="email" formControlName="userLockoutNotificationEmail"/> </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 class="fields-group"> diff --git a/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts index c7ac489753..712e2bb358 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts +++ b/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({ maxFailedLoginAttempts: [null, [Validators.min(0)]], 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( { minimumLength: [null, [Validators.required, Validators.min(6), Validators.max(50)]], diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.html index 9fd461c229..15322b38d4 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component.html +++ b/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> <mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic"> <mat-select formControlName="disableOnProperty" placeholder="{{ 'widget-config.set' | translate }}"> + <mat-option [value]="null"></mat-option> <mat-option *ngFor="let prop of booleanPropertyIds" [value]="prop"> {{ prop }} </mat-option> diff --git a/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html b/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html index cabb688525..d03b50418d 100644 --- a/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html +++ b/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 mat-dialog-content tb-toast toastTarget="activationLinkDialogContent"> <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"> <pre class="tb-highlight" fxFlex><code>{{ activationLink }}</code></pre> <button mat-icon-button diff --git a/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.ts index d2c2037d6b..598cda29ef 100644 --- a/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.ts @@ -14,7 +14,7 @@ /// 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 { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -22,29 +22,31 @@ import { TranslateService } from '@ngx-translate/core'; import { ActionNotificationShow } from '@core/notification/notification.actions'; import { DialogComponent } from '@shared/components/dialog.component'; 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 { - activationLink: string; + activationLinkInfo: ActivationLinkInfo; } @Component({ selector: 'tb-activation-link-dialog', templateUrl: './activation-link-dialog.component.html' }) -export class ActivationLinkDialogComponent extends DialogComponent<ActivationLinkDialogComponent, void> implements OnInit { +export class ActivationLinkDialogComponent extends DialogComponent<ActivationLinkDialogComponent, void> { activationLink: string; + activationLinkTtl: string; constructor(protected store: Store<AppState>, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: ActivationLinkDialogData, public dialogRef: MatDialogRef<ActivationLinkDialogComponent, void>, - private translate: TranslateService) { + private translate: TranslateService, + private millisecondsToTimeStringPipe: MillisecondsToTimeStringPipe) { super(store, router, dialogRef); - this.activationLink = this.data.activationLink; - } - - ngOnInit(): void { + this.activationLink = this.data.activationLinkInfo.value; + this.activationLinkTtl = this.millisecondsToTimeStringPipe.transform(this.data.activationLinkInfo.ttlMs); } close(): void { diff --git a/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts index 73bacc4c17..37c55c40c2 100644 --- a/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts +++ b/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 { UserComponent } from '@modules/home/pages/user/user.component'; 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 { UserService } from '@core/http/user.service'; import { Observable } from 'rxjs'; @@ -88,7 +88,7 @@ export class AddUserDialogComponent extends DialogComponent<AddUserDialogCompone this.userService.saveUser(this.user, sendActivationEmail).subscribe( (user) => { if (this.activationMethod === ActivationMethod.DISPLAY_ACTIVATION_LINK) { - this.userService.getActivationLink(user.id.id).subscribe( + this.userService.getActivationLinkInfo(user.id.id).subscribe( (activationLink) => { 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, void>(ActivationLinkDialogComponent, { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { - activationLink + activationLinkInfo } }).afterClosed(); } diff --git a/ui-ngx/src/app/modules/home/pages/user/users-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/user/users-table-config.resolver.ts index 6e762064cc..a8f7df963f 100644 --- a/ui-ngx/src/app/modules/home/pages/user/users-table-config.resolver.ts +++ b/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) { $event.stopPropagation(); } - this.userService.getActivationLink(user.id.id).subscribe( - (activationLink) => { + this.userService.getActivationLinkInfo(user.id.id).subscribe( + (activationLinkInfo) => { this.dialog.open<ActivationLinkDialogComponent, ActivationLinkDialogData, void>(ActivationLinkDialogComponent, { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { - activationLink + activationLinkInfo } }); } diff --git a/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html index 8d0f6bd6f5..804730dbfe 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html @@ -39,6 +39,7 @@ </mat-form-field> <tb-widgets-bundle-select formControlName="widgetsBundle" + createNew bundlesScope="{{bundlesScope}}"> </tb-widgets-bundle-select> </div> diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library.module.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-library.module.ts index 1ee25529b0..80438b4f4d 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-library.module.ts +++ b/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 { WidgetsBundleWidgetsComponent } from '@home/pages/widget/widgets-bundle-widgets.component'; import { WidgetTypeAutocompleteComponent } from '@home/pages/widget/widget-type-autocomplete.component'; +import { WidgetsBundleDialogComponent } from '@home/pages/widget/widgets-bundle-dialog.component'; @NgModule({ declarations: [ @@ -39,7 +40,8 @@ import { WidgetTypeAutocompleteComponent } from '@home/pages/widget/widget-type- SelectWidgetTypeDialogComponent, SaveWidgetTypeAsDialogComponent, WidgetTypeTabsComponent, - WidgetsBundleTabsComponent + WidgetsBundleTabsComponent, + WidgetsBundleDialogComponent ], imports: [ CommonModule, diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.html b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.html new file mode 100644 index 0000000000..592be51486 --- /dev/null +++ b/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> diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.scss new file mode 100644 index 0000000000..0556c6bd25 --- /dev/null +++ b/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; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.ts new file mode 100644 index 0000000000..bb5f93cb66 --- /dev/null +++ b/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); + }); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html index f95d085688..df66eee579 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html @@ -15,7 +15,7 @@ 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" [disabled]="(isLoading$ | async)" (click)="onEntityAction($event, 'open')" diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.ts index ca0912ea11..5b0d118313 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.ts @@ -14,7 +14,7 @@ /// 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 { AppState } from '@core/core.state'; 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> { + @Input() + standalone = false; + constructor(protected store: Store<AppState>, - @Inject('entity') protected entityValue: WidgetsBundle, - @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig<WidgetsBundle>, + @Optional() @Inject('entity') protected entityValue: WidgetsBundle, + @Optional() @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig<WidgetsBundle>, public fb: UntypedFormBuilder, protected cd: ChangeDetectorRef) { super(store, fb, entityValue, entitiesTableConfigValue, cd); diff --git a/ui-ngx/src/app/modules/login/login-routing.module.ts b/ui-ngx/src/app/modules/login/login-routing.module.ts index bfebce37a1..4b569f2e9f 100644 --- a/ui-ngx/src/app/modules/login/login-routing.module.ts +++ b/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 { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-factor-auth-login.component'; import { Authority } from '@shared/models/authority.enum'; +import { LinkExpiredComponent } from '@modules/login/pages/login/link-expired.component'; const routes: Routes = [ { @@ -81,6 +82,25 @@ const routes: Routes = [ module: 'public' }, 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] } ]; diff --git a/ui-ngx/src/app/modules/login/login.module.ts b/ui-ngx/src/app/modules/login/login.module.ts index 0a664133e4..d995d9b2a6 100644 --- a/ui-ngx/src/app/modules/login/login.module.ts +++ b/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 { CreatePasswordComponent } from '@modules/login/pages/login/create-password.component'; import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-factor-auth-login.component'; +import { LinkExpiredComponent } from '@modules/login/pages/login/link-expired.component'; @NgModule({ declarations: [ @@ -31,7 +32,8 @@ import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-fact ResetPasswordRequestComponent, ResetPasswordComponent, CreatePasswordComponent, - TwoFactorAuthLoginComponent + TwoFactorAuthLoginComponent, + LinkExpiredComponent ], imports: [ CommonModule, diff --git a/ui-ngx/src/app/modules/login/pages/login/link-expired.component.html b/ui-ngx/src/app/modules/login/pages/login/link-expired.component.html new file mode 100644 index 0000000000..a1d52f0735 --- /dev/null +++ b/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> diff --git a/ui-ngx/src/app/modules/login/pages/login/link-expired.component.scss b/ui-ngx/src/app/modules/login/pages/login/link-expired.component.scss new file mode 100644 index 0000000000..323437e66b --- /dev/null +++ b/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; + } + } + } +} diff --git a/ui-ngx/src/app/modules/login/pages/login/link-expired.component.ts b/ui-ngx/src/app/modules/login/pages/login/link-expired.component.ts new file mode 100644 index 0000000000..d394e426dd --- /dev/null +++ b/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'); + } +} diff --git a/ui-ngx/src/app/shared/components/image/image-gallery.component.ts b/ui-ngx/src/app/shared/components/image/image-gallery.component.ts index 23f489d78c..24a4c3dde7 100644 --- a/ui-ngx/src/app/shared/components/image/image-gallery.component.ts +++ b/ui-ngx/src/app/shared/components/image/image-gallery.component.ts @@ -639,11 +639,15 @@ export class ImageGalleryComponent extends PageComponent implements OnInit, OnDe } rowClick($event, image: ImageResourceInfo) { - if (this.selectionMode) { - this.selectImage($event, image); + if (this.isScada) { + this.editImage($event, image); } else { - if (this.deleteEnabled(image)) { - this.dataSource.selection.toggle(image); + if (this.selectionMode) { + this.selectImage($event, image); + } else { + if (this.deleteEnabled(image)) { + this.dataSource.selection.toggle(image); + } } } } diff --git a/ui-ngx/src/app/shared/components/markdown.component.ts b/ui-ngx/src/app/shared/components/markdown.component.ts index fc7c2d0d8d..069bca2b2d 100644 --- a/ui-ngx/src/app/shared/components/markdown.component.ts +++ b/ui-ngx/src/app/shared/components/markdown.component.ts @@ -160,7 +160,7 @@ export class TbMarkdownComponent implements OnChanges { }, template, compileModules, - true, 1, styles + true, styles ).subscribe((componentData) => { this.tbMarkdownInstanceComponentType = componentData.componentType; const injector: Injector = Injector.create({providers: [], parent: this.markdownContainer.injector}); diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-select.component.html b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.html index 2f3f0199e4..2826d06d42 100644 --- a/ui-ngx/src/app/shared/components/widgets-bundle-select.component.html +++ b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.html @@ -22,6 +22,7 @@ panelClass="tb-widgets-bundle-select" placeholder="{{ 'widget.select-widgets-bundle' | translate }}" (blur)="onTouched()" + [compareWith]="compareById" (ngModelChange)="widgetsBundleChanged()"> <mat-select-trigger> <div class="tb-bundle-item"> @@ -29,8 +30,12 @@ <span translate class="tb-bundle-system" *ngIf="isSystem(widgetsBundle)">widgets-bundle.system</span> </div> </mat-select-trigger> - <mat-option [value]="null" *ngIf="!required"> + <mat-option *ngIf="createNew; else empty" [value]="null" (click)="openWidgetsBundleDialog($event)"> + <a translate>widgets-bundle.create-new</a> </mat-option> + <ng-template #empty> + <mat-option [value]="null" *ngIf="!required"></mat-option> + </ng-template> <mat-option *ngFor="let widgetsBundle of widgetsBundles$ | async" [value]="widgetsBundle"> <div class="tb-bundle-item"> <span>{{widgetsBundle.title}}</span> diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-select.component.ts b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.ts index d81fcbe88e..b5baccc893 100644 --- a/ui-ngx/src/app/shared/components/widgets-bundle-select.component.ts +++ b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.ts @@ -14,9 +14,18 @@ /// limitations under the License. /// -import { Component, forwardRef, Input, OnChanges, OnInit, SimpleChanges, ViewEncapsulation } from '@angular/core'; +import { + ChangeDetectorRef, + Component, + forwardRef, + Input, + OnChanges, + OnInit, + SimpleChanges, + ViewEncapsulation +} from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { map, share, tap } from 'rxjs/operators'; import { Store } from '@ngrx/store'; import { AppState } from '@app/core/core.state'; @@ -26,6 +35,11 @@ import { isDefined } from '@core/utils'; import { NULL_UUID } from '@shared/models/id/has-uuid'; import { getCurrentAuthState } from '@core/auth/auth.selectors'; import { coerceBoolean } from '@shared/decorators/coercion'; +import { MatDialog } from '@angular/material/dialog'; +import { + WidgetsBundleDialogComponent, + WidgetsBundleDialogData +} from '@home/pages/widget/widgets-bundle-dialog.component'; @Component({ selector: 'tb-widgets-bundle-select', @@ -60,6 +74,10 @@ export class WidgetsBundleSelectComponent implements ControlValueAccessor, OnIni @Input() excludeBundleIds: Array<string>; + @Input() + @coerceBoolean() + createNew: boolean; + widgetsBundles$: Observable<Array<WidgetsBundle>>; widgetsBundles: Array<WidgetsBundle>; @@ -70,7 +88,9 @@ export class WidgetsBundleSelectComponent implements ControlValueAccessor, OnIni private propagateChange: (value: any) => void = () => {}; constructor(private store: Store<AppState>, - private widgetService: WidgetService) { + private widgetService: WidgetService, + private dialog: MatDialog, + private cd: ChangeDetectorRef) { } registerOnChange(fn: any): void { @@ -171,4 +191,35 @@ export class WidgetsBundleSelectComponent implements ControlValueAccessor, OnIni return widgetsBundlesObservable; } + compareById(f1: WidgetsBundle, f2: WidgetsBundle): boolean { + return f1 && f2 && f1.id.id === f2.id.id; + } + + openWidgetsBundleDialog($event) { + $event.preventDefault(); + const widgetsBundle: WidgetsBundle = { + title: '', + image: '', + description: '', + scada: true, + order: null + }; + this.dialog.open<WidgetsBundleDialogComponent, WidgetsBundleDialogData, + WidgetsBundle>(WidgetsBundleDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + widgetsBundle + } + }).afterClosed().subscribe( + (savedWidgetBundle) => { + if (savedWidgetBundle) { + this.widgetsBundles$ = of([...this.widgetsBundles, savedWidgetBundle]); + this.widgetsBundle = savedWidgetBundle; + this.widgetsBundleChanged(); + } + } + ); + } + } diff --git a/ui-ngx/src/app/shared/models/settings.models.ts b/ui-ngx/src/app/shared/models/settings.models.ts index 113bc1a789..e0a31e4eea 100644 --- a/ui-ngx/src/app/shared/models/settings.models.ts +++ b/ui-ngx/src/app/shared/models/settings.models.ts @@ -111,6 +111,11 @@ export interface UserPasswordPolicy { export interface SecuritySettings { passwordPolicy: UserPasswordPolicy; + maxFailedLoginAttempts: number; + userLockoutNotificationEmail: string; + mobileSecretKeyLength: number; + userActivationTokenTtl: number; + passwordResetTokenTtl: number; } export interface JwtSettings { diff --git a/ui-ngx/src/app/shared/models/user.model.ts b/ui-ngx/src/app/shared/models/user.model.ts index 5b2cdec08b..a17cf040a8 100644 --- a/ui-ngx/src/app/shared/models/user.model.ts +++ b/ui-ngx/src/app/shared/models/user.model.ts @@ -44,6 +44,11 @@ export const activationMethodTranslations = new Map<ActivationMethod, string>( ] ); +export interface ActivationLinkInfo { + value: string; + ttlMs: number; +} + export interface AuthUser { sub: string; scopes: string[]; diff --git a/ui-ngx/src/app/shared/models/widgets-bundle.model.ts b/ui-ngx/src/app/shared/models/widgets-bundle.model.ts index 7066defdf7..d0d2731ac2 100644 --- a/ui-ngx/src/app/shared/models/widgets-bundle.model.ts +++ b/ui-ngx/src/app/shared/models/widgets-bundle.model.ts @@ -20,8 +20,7 @@ import { WidgetsBundleId } from '@shared/models/id/widgets-bundle-id'; import { HasTenantId, HasVersion } from '@shared/models/entity.models'; export interface WidgetsBundle extends BaseData<WidgetsBundleId>, HasTenantId, HasVersion, ExportableEntity<WidgetsBundleId> { - tenantId: TenantId; - alias: string; + alias?: string; title: string; image: string; scada: boolean; diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 369985740d..2b5a33a9b3 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -205,6 +205,12 @@ "max-failed-login-attempts": "Maximum number of failed login attempts, before account is locked", "minimum-max-failed-login-attempts-range": "Maximum number of failed login attempts can't be negative", "user-lockout-notification-email": "In case user account lockout, send notification to email", + "user-activation-token-ttl": "User activation link TTL in hours", + "user-activation-token-ttl-range": "User activation link TTL must be in range from 1 to 24 hours", + "password-reset-token-ttl": "Password reset link TTL in hours", + "password-reset-token-ttl-range": "Password reset link TTL must be in range from 1 to 24 hours", + "mobile-secret-key-length": "Mobile secret key length", + "mobile-secret-key-length-range": "Mobile secret key length must be positive", "domain-name": "Domain name", "domain-name-unique": "Domain name and protocol need to unique.", "domain-name-max-length": "Domain name should be less than 256", @@ -4014,7 +4020,11 @@ "email-auth-description": "A security code has been sent to your email address at {{contact}}.", "email-auth-placeholder": "Email code", "backup-code-auth-description": "Please enter one of your backup codes.", - "backup-code-auth-placeholder": "Backup code" + "backup-code-auth-placeholder": "Backup code", + "activation-link-expired": "Activation link has expired", + "activation-link-expired-message": "The link to activate your profile has expired. You can return to the login page to receive a new email.", + "reset-password-link-expired": "Password reset link has expired", + "reset-password-link-expired-message": "The link to reset your password has expired. You can return to the login page to receive a new email." }, "markdown": { "edit": "Edit", @@ -5634,7 +5644,7 @@ "display-activation-link": "Display activation link", "send-activation-mail": "Send activation mail", "activation-link": "User activation link", - "activation-link-text": "In order to activate user use the following <a href='{{activationLink}}' target='_blank'>activation link</a> :", + "activation-link-text": "In order to activate user use the following <a href='{{activationLink}}' target='_blank'>activation link</a> (expires in {{activationLinkTtl}}) :", "copy-activation-link": "Copy activation link", "activation-link-copied-message": "User activation link has been copied to clipboard", "details": "Details", @@ -5951,7 +5961,8 @@ "search": "Search widget bundles", "selected-widgets-bundles": "{ count, plural, =1 {1 widgets bundle} other {# widgets bundles} } selected", "open-widgets-bundle": "Open widgets bundle", - "loading-widgets-bundles": "Loading widgets bundles..." + "loading-widgets-bundles": "Loading widgets bundles...", + "create-new": "Create new widget bundle" }, "widget-config": { "data": "Data",