Repository: victronenergy/gui-v2
Pull request #2097: Allow user to update the security profile password easily
Author: login: chriadam
Created: 2025-05-05T03:33:31Z | Merged: 2025-05-07T06:05:54Z

Add a button which allows the user to update the password without changing the security profile selection.

Contributes to issue #1993


Diff:
diff --git a/CMakeLists.txt b/CMakeLists.txt
index d7c399ccc..4949889cf 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -321,6 +321,7 @@ set (VENUS_QML_MODULE_SOURCES
     components/dialogs/ModalDialog.qml
     components/dialogs/ModalWarningDialog.qml
     components/dialogs/NumberSelectorDialog.qml
+    components/dialogs/SecurityProfilePasswordDialog.qml
     components/dialogs/SolarDailyHistoryDialog.qml
     components/dialogs/TimeSelectorDialog.qml
 
diff --git a/components/RadioButtonListPage.qml b/components/RadioButtonListPage.qml
index 37cc7b0b8..32c03afb1 100644
--- a/components/RadioButtonListPage.qml
+++ b/components/RadioButtonListPage.qml
@@ -21,6 +21,8 @@ Page {
 	property var popDestination: null
 	property var validatePassword
 
+	property alias footer: optionsListView.footer
+
 	signal optionClicked(index: int, value : var)
 	signal aboutToPop()
 
diff --git a/components/dialogs/SecurityProfilePasswordDialog.qml b/components/dialogs/SecurityProfilePasswordDialog.qml
new file mode 100644
index 000000000..8d4fdf0ed
--- /dev/null
+++ b/components/dialogs/SecurityProfilePasswordDialog.qml
@@ -0,0 +1,123 @@
+/*
+** Copyright (C) 2025 Victron Energy B.V.
+** See LICENSE.txt for license information.
+*/
+
+import QtQuick
+import QtQuick.Layouts
+import Victron.VenusOS
+
+ModalDialog {
+	id: root
+
+	property string password
+
+	function validate() : bool {
+
+		root.password = ""
+
+		if (firstPassword.length < 8) {
+
+			// First, validate that the firstPassword is at least 8 characters long.
+			// If not, the border turns red the passwordHint warning about length
+			// becomes visible.
+
+			firstPassword.borderColor = Theme.color_critical
+			passwordHint.visible = true
+			//% "Password needs to be at least 8 characters long"
+			passwordHint.text = qsTrId("settings_security_profile_password_incorrect_length")
+
+			return false
+		}
+
+		resetValidation()
+
+		root.password = firstPassword.text
+
+		return true
+	}
+
+	function resetValidation() {
+
+		// As soon as the input in any input field changes,
+		// the borderColor turns back to blue and the red passwordHint turns invisible.
+		// This is also this is ths default "validated" state
+
+		firstPassword.borderColor = Theme.color_ok
+		passwordHint.visible = false
+		passwordHint.text = ""
+	}
+
+	//% "Changing the GX Password"
+	title: qsTrId("settings_security_profile_change_password_title")
+
+	//% "Confirm"
+	acceptText: qsTrId("modaldialog_confirm")
+
+	dialogDoneOptions: VenusOS.ModalDialog_DoneOptions_OkAndCancel
+
+	Component.onCompleted: resetValidation()
+
+	contentItem: Item {
+		id: passwordEntryItem
+
+		ColumnLayout {
+			anchors {
+				top: parent.top
+				topMargin: Theme.geometry_modalWarningDialog_title_spacing
+				horizontalCenter: parent.horizontalCenter
+			}
+
+			spacing: Theme.geometry_modalWarningDialog_title_spacing
+
+			Label {
+				id: description
+
+				Layout.fillWidth: true
+
+				//% "Please enter a new GX password:"
+				text: qsTrId("settings_security_profile_change_password_description")
+
+				font.pixelSize: Theme.font_size_body2
+				horizontalAlignment: Label.AlignHCenter
+				wrapMode: Text.Wrap
+			}
+
+			TextField {
+				id: firstPassword
+
+				Layout.fillWidth: true
+
+				focus: true
+
+				//% "Enter new password"
+				placeholderText: qsTrId("settings_security_profile_enter_new_password")
+				echoMode: TextInput.Password
+
+				// As soon as the input in any input field changes,
+				// the borderColors turns back to blue and the red passwordHint turns invisible.
+				onTextEdited: root.resetValidation()
+
+				// When Return/Enter key is pressed (and as per current behaviour onFocusLost),
+				// the password shall be validated
+				onAccepted: root.acceptButton?.clicked()
+				onActiveFocusChanged: if(!focus) root.validate()
+			}
+
+			Label {
+				id: passwordHint
+
+				Layout.fillWidth: true
+				font.pixelSize: Theme.font_size_caption
+				color: Theme.color_red
+				horizontalAlignment: Label.AlignHCenter
+				wrapMode: Text.Wrap
+			}
+		}
+	}
+
+	tryAccept: function() {
+		// When “Confirm” is tapped the passwords shall be validated
+		return root.validate()
+	}
+}
diff --git a/components/listitems/core/ListRadioButtonGroup.qml b/components/listitems/core/ListRadioButtonGroup.qml
index 051dd2f92..a8aed399f 100644
--- a/components/listitems/core/ListRadioButtonGroup.qml
+++ b/components/listitems/core/ListRadioButtonGroup.qml
@@ -43,6 +43,8 @@ ListNavigation {
 	//% "Unknown"
 	property string defaultSecondaryText: qsTrId("settings_radio_button_group_unknown")
 
+	property Component optionFooter
+
 	signal optionClicked(index: int)
 	signal aboutToPop()
 
@@ -65,6 +67,7 @@ ListNavigation {
 		id: optionsPageComponent
 
 		RadioButtonListPage {
+			footer: root.optionFooter
 			optionModel: root.optionModel
 			currentIndex: root.currentIndex
 			updateCurrentIndexOnClick: root.updateCurrentIndexOnClick
diff --git a/pages/settings/PageSettingsAccessAndSecurity.qml b/pages/settings/PageSettingsAccessAndSecurity.qml
index c0f4f75ef..ff4e13eb9 100644
--- a/pages/settings/PageSettingsAccessAndSecurity.qml
+++ b/pages/settings/PageSettingsAccessAndSecurity.qml
@@ -98,6 +98,25 @@ Page {
 				property int pendingProfile
 				property string pendingPassword
 
+				function setProfileAndPassword(profile, password, popPage) {
+					securityProfile.currentIndex = profile
+					// NOTE: this restarts the webserver when changed
+					var object = {"SetPassword": password, "SetSecurityProfile": profile};
+					var json = JSON.stringify(object);
+					securityApi.setValue(json);
+					// This guards the wasm version to trigger a reload even if the reply isn't received.
+					BackendConnection.securityProtocolChanged()
+					if (popPage) {
+						Global.pageManager.popPage()
+					}
+					if (Qt.platform.os === "wasm" && !BackendConnection.vrm) {
+						Global.showToastNotification(VenusOS.Notification_Info,
+													 //% "Page will automatically reload in 5 seconds"
+													 qsTrId("access_and_security_page_will_reload"),
+													 3000)
+					}
+				}
+
 				//% "Local network security profile"
 				text: qsTrId("settings_local_network_security_profile")
 				dataItem.uid: Global.systemSettings.serviceUid + "/Settings/System/SecurityProfile"
@@ -151,6 +170,35 @@ Page {
 					}
 				}
 
+				optionFooter: Column {
+					visible: securityProfile.currentIndex !== VenusOS.Security_Profile_Unsecured
+					width: parent.width
+					topPadding: Theme.geometry_gradientList_spacing
+
+					ListButton {
+						//% "Change password"
+						text: qsTrId("settings_security_profile_change_password")
+						//% "Update"
+						secondaryText: qsTrId("settings_security_profile_update")
+
+						onClicked: {
+							Global.dialogLayer.open(securityProfilePasswordDialogComponent)
+						}
+
+						Component {
+							id: securityProfilePasswordDialogComponent
+
+							SecurityProfilePasswordDialog {
+								id: securityProfilePasswordDialog
+
+								onAccepted: {
+									securityProfile.setProfileAndPassword(securityProfile.currentIndex, password, false)
+								}
+							}
+						}
+					}
+				}
+
 				VeQuickItem {
 					id: securityApi
 					uid: Global.venusPlatform.serviceUid + "/Security/Api"
@@ -194,20 +242,7 @@ Page {
 							const profile = securityProfile.pendingProfile
 							if (profile === VenusOS.Security_Profile_Unsecured)
 								password = "";
-							securityProfile.currentIndex = profile
-							// NOTE: this restarts the webserver when changed
-							var object = {"SetPassword": password, "SetSecurityProfile": profile};
-							var json = JSON.stringify(object);
-							securityApi.setValue(json);
-							// This guards the wasm version to trigger a reload even if the reply isn't received.
-							BackendConnection.securityProtocolChanged()
-							Global.pageManager.popPage()
-							if (Qt.platform.os === "wasm" && !BackendConnection.vrm) {
-								Global.showToastNotification(VenusOS.Notification_Info,
-															 //% "Page will automatically reload in 5 seconds"
-															 qsTrId("access_and_security_page_will_reload"),
-															 3000)
-							}
+							securityProfile.setProfileAndPassword(profile, password, true)
 						}
 						dialogDoneOptions: VenusOS.ModalDialog_DoneOptions_OkAndCancel
 						height: securityProfile.pendingProfile === VenusOS.Security_Profile_Secured


Reviews:
user: login: philipptrenz
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-06T09:34:21Z
user: login: blammit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T03:53:17Z
user: login: chriadam
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T03:54:30Z
user: login: chriadam
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T03:54:35Z
user: login: chriadam
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T03:54:41Z
user: login: DanielMcInnes
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T05:57:37Z

Review comments:
diff_hunk: @@ -0,0 +1,123 @@
+/*
+** Copyright (C) 2025 Victron Energy B.V.
+** See LICENSE.txt for license information.
+*/
+
+import QtQuick
+import QtQuick.Layouts
+import Victron.VenusOS
+
+ModalDialog {
+	id: root
+
+	//% "Changing the GX Password"
+	title: qsTrId("settings_security_profile_change_password_title")
+
+	//% "Confirm"
+	acceptText: qsTrId("modaldialog_confirm")
+
+	dialogDoneOptions: VenusOS.ModalDialog_DoneOptions_OkAndCancel
+
+	Component.onCompleted: resetValidation()
+
+	property string password
+
+	function valiate() : bool {
path: components/dialogs/SecurityProfilePasswordDialog.qml
user: login: blammit
body: typo "valiate"
created_at: 2025-05-07T03:44:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,123 @@
+/*
+** Copyright (C) 2025 Victron Energy B.V.
+** See LICENSE.txt for license information.
+*/
+
+import QtQuick
+import QtQuick.Layouts
+import Victron.VenusOS
+
+ModalDialog {
+	id: root
+
+	//% "Changing the GX Password"
+	title: qsTrId("settings_security_profile_change_password_title")
+
+	//% "Confirm"
+	acceptText: qsTrId("modaldialog_confirm")
+
+	dialogDoneOptions: VenusOS.ModalDialog_DoneOptions_OkAndCancel
+
+	Component.onCompleted: resetValidation()
+
+	property string password
path: components/dialogs/SecurityProfilePasswordDialog.qml
user: login: blammit
body: Put properties and functions above property assignments?
created_at: 2025-05-07T03:44:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -43,6 +43,8 @@ ListNavigation {
 	//% "Unknown"
 	property string defaultSecondaryText: qsTrId("settings_radio_button_group_unknown")
 
+	property Component footer
path: components/listitems/core/ListRadioButtonGroup.qml
user: login: blammit
body: Should this be `optionFooter` to indicate that the footer is shown in the sub-page of options, rather than inside the initial list item?
created_at: 2025-05-07T03:52:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -43,6 +43,8 @@ ListNavigation {
 	//% "Unknown"
 	property string defaultSecondaryText: qsTrId("settings_radio_button_group_unknown")
 
+	property Component footer
path: components/listitems/core/ListRadioButtonGroup.qml
user: login: chriadam
body: makes sense, will do
created_at: 2025-05-07T03:54:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,123 @@
+/*
+** Copyright (C) 2025 Victron Energy B.V.
+** See LICENSE.txt for license information.
+*/
+
+import QtQuick
+import QtQuick.Layouts
+import Victron.VenusOS
+
+ModalDialog {
+	id: root
+
+	//% "Changing the GX Password"
+	title: qsTrId("settings_security_profile_change_password_title")
+
+	//% "Confirm"
+	acceptText: qsTrId("modaldialog_confirm")
+
+	dialogDoneOptions: VenusOS.ModalDialog_DoneOptions_OkAndCancel
+
+	Component.onCompleted: resetValidation()
+
+	property string password
+
+	function valiate() : bool {
path: components/dialogs/SecurityProfilePasswordDialog.qml
user: login: chriadam
body: thanks, will fix
created_at: 2025-05-07T03:54:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,123 @@
+/*
+** Copyright (C) 2025 Victron Energy B.V.
+** See LICENSE.txt for license information.
+*/
+
+import QtQuick
+import QtQuick.Layouts
+import Victron.VenusOS
+
+ModalDialog {
+	id: root
+
+	//% "Changing the GX Password"
+	title: qsTrId("settings_security_profile_change_password_title")
+
+	//% "Confirm"
+	acceptText: qsTrId("modaldialog_confirm")
+
+	dialogDoneOptions: VenusOS.ModalDialog_DoneOptions_OkAndCancel
+
+	Component.onCompleted: resetValidation()
+
+	property string password
path: components/dialogs/SecurityProfilePasswordDialog.qml
user: login: chriadam
body: will do
created_at: 2025-05-07T03:54:41Z
author_association: CONTRIBUTOR

PR comments:
user: login: chriadam
created_at: 2025-05-05T03:34:39Z
body: @philipptrenz testing / feedback appreciated
author_association: CONTRIBUTOR
user: login: chriadam
created_at: 2025-05-05T03:37:09Z
body: I haven't yet tested on a real device, as the power is off at the office today, so cannot use our cerbo to test this properly unfortunately :-/  So, only tested with mock mode.  Testing appreciated!
author_association: CONTRIBUTOR
user: login: chriadam
created_at: 2025-05-06T02:57:11Z
body: Thanks for the fix, Mike!  I've now tested on CerboGX and it seems to work nicely.  More testing is of course appreciated.  I will send a build to Philipp for extra testing / feedback.
author_association: CONTRIBUTOR
user: login: chriadam
created_at: 2025-05-06T08:29:07Z
body: Philipp asked me to simplify the update dialog by only asking for a single password to be entered.  I have updated the PR with this change now, also.
author_association: CONTRIBUTOR
user: login: philipptrenz
created_at: 2025-05-06T08:55:41Z
body: @chriadam I tested the latest changes and it looks and works as expected 👍
author_association: COLLABORATOR