Browse Source

Merge pull request #7743 from Ultimaker/CURA-7427_Add_option_to_sign_in_with_different_account_while_waiting_for_printers

Cura 7427 add option to sign in with different account while waiting for printers
Nino van Hooff 4 years ago
parent
commit
f34e05ac03

+ 16 - 4
cura/API/Account.py

@@ -163,11 +163,23 @@ class Account(QObject):
                     self._update_timer.stop()
 
     @pyqtSlot()
-    def login(self) -> None:
+    @pyqtSlot(bool)
+    def login(self, force_logout_before_login: bool = False) -> None:
+        """
+        Initializes the login process. If the user is logged in already and force_logout_before_login is true, Cura will
+        logout from the account before initiating the authorization flow. If the user is logged in and
+        force_logout_before_login is false, the function will return, as there is nothing to do.
+
+        :param force_logout_before_login: Optional boolean parameter
+        :return: None
+        """
         if self._logged_in:
-            # Nothing to do, user already logged in.
-            return
-        self._authorization_service.startAuthorizationFlow()
+            if force_logout_before_login:
+                self.logout()
+            else:
+                # Nothing to do, user already logged in.
+                return
+        self._authorization_service.startAuthorizationFlow(force_logout_before_login)
 
     @pyqtProperty(str, notify=loginStateChanged)
     def userName(self):

+ 28 - 8
cura/OAuth2/AuthorizationService.py

@@ -3,8 +3,8 @@
 
 import json
 from datetime import datetime, timedelta
-from typing import Optional, TYPE_CHECKING
-from urllib.parse import urlencode
+from typing import Optional, TYPE_CHECKING, Dict
+from urllib.parse import urlencode, quote_plus
 
 import requests.exceptions
 from PyQt5.QtCore import QUrl
@@ -24,6 +24,7 @@ if TYPE_CHECKING:
     from cura.OAuth2.Models import UserProfile, OAuth2Settings
     from UM.Preferences import Preferences
 
+MYCLOUD_LOGOFF_URL = "https://mycloud.ultimaker.com/logoff"
 
 ##  The authorization service is responsible for handling the login flow,
 #   storing user credentials and providing account information.
@@ -142,7 +143,7 @@ class AuthorizationService:
             self.onAuthStateChanged.emit(logged_in = False)
 
     ##  Start the flow to become authenticated. This will start a new webbrowser tap, prompting the user to login.
-    def startAuthorizationFlow(self) -> None:
+    def startAuthorizationFlow(self, force_browser_logout: bool = False) -> None:
         Logger.log("d", "Starting new OAuth2 flow...")
 
         # Create the tokens needed for the code challenge (PKCE) extension for OAuth2.
@@ -153,8 +154,8 @@ class AuthorizationService:
 
         state = AuthorizationHelpers.generateVerificationCode()
 
-        # Create the query string needed for the OAuth2 flow.
-        query_string = urlencode({
+        # Create the query dict needed for the OAuth2 flow.
+        query_parameters_dict = {
             "client_id": self._settings.CLIENT_ID,
             "redirect_uri": self._settings.CALLBACK_URL,
             "scope": self._settings.CLIENT_SCOPES,
@@ -162,7 +163,7 @@ class AuthorizationService:
             "state": state,  # Forever in our Hearts, RIP "(.Y.)" (2018-2020)
             "code_challenge": challenge_code,
             "code_challenge_method": "S512"
-        })
+        }
 
         # Start a local web server to receive the callback URL on.
         try:
@@ -173,9 +174,28 @@ class AuthorizationService:
                     title=i18n_catalog.i18nc("@info:title", "Warning")).show()
             return
 
+        auth_url = self._generate_auth_url(query_parameters_dict, force_browser_logout)
         # Open the authorization page in a new browser window.
-        QDesktopServices.openUrl(QUrl("{}?{}".format(self._auth_url, query_string)))
-
+        QDesktopServices.openUrl(QUrl(auth_url))
+
+    def _generate_auth_url(self, query_parameters_dict: Dict[str, Optional[str]], force_browser_logout: bool) -> str:
+        """
+        Generates the authentications url based on the original auth_url and the query_parameters_dict to be included.
+        If there is a request to force logging out of mycloud in the browser, the link to logoff from mycloud is
+        prepended in order to force the browser to logoff from mycloud and then redirect to the authentication url to
+        login again. This case is used to sync the accounts between Cura and the browser.
+
+        :param query_parameters_dict: A dictionary with the query parameters to be url encoded and added to the
+                                      authentication link
+        :param force_browser_logout: If True, Cura will prepend the MYCLOUD_LOGOFF_URL link before the authentication
+                                     link to force the a browser logout from mycloud.ultimaker.com
+        :return: The authentication URL, properly formatted and encoded
+        """
+        auth_url = "{}?{}".format(self._auth_url, urlencode(query_parameters_dict))
+        if force_browser_logout:
+            # The url after '?next=' should be urlencoded
+            auth_url = "{}?next={}".format(MYCLOUD_LOGOFF_URL, quote_plus(auth_url))
+        return auth_url
 
     ##  Callback method for the authentication flow.
     def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None:

+ 33 - 2
resources/qml/WelcomePages/AddCloudPrintersView.qml

@@ -51,11 +51,11 @@ Item
         }
 
         // Component that contains a busy indicator and a message, while it waits for Cura to discover a cloud printer
-        Rectangle
+        Item
         {
             id: waitingContent
             width: parent.width
-            height: waitingIndicator.height + waitingLabel.height
+            height: childrenRect.height
             anchors.verticalCenter: parent.verticalCenter
             anchors.horizontalCenter: parent.horizontalCenter
             BusyIndicator
@@ -74,6 +74,37 @@ Item
                 font: UM.Theme.getFont("large")
                 renderType: Text.NativeRendering
             }
+            Label
+            {
+                id: noPrintersFoundLabel
+                anchors.top: waitingLabel.bottom
+                anchors.topMargin: 2 * UM.Theme.getSize("wide_margin").height
+                anchors.horizontalCenter: parent.horizontalCenter
+                horizontalAlignment: Text.AlignHCenter
+                text: catalog.i18nc("@label", "No printers found in your account?")
+                font: UM.Theme.getFont("medium")
+            }
+            Label
+            {
+                text: "Sign in with a different account"
+                anchors.top: noPrintersFoundLabel.bottom
+                anchors.horizontalCenter: parent.horizontalCenter
+                font: UM.Theme.getFont("medium")
+                color: UM.Theme.getColor("text_link")
+                MouseArea {
+                    anchors.fill: parent;
+                    onClicked: Cura.API.account.login(true)
+                    hoverEnabled: true
+                    onEntered:
+                    {
+                        parent.font.underline = true
+                    }
+                    onExited:
+                    {
+                        parent.font.underline = false
+                    }
+                }
+            }
             visible: discoveredCloudPrintersModel.count == 0
         }
 

+ 1 - 1
resources/qml/WelcomePages/AddNetworkOrLocalPrinterContent.qml

@@ -75,7 +75,7 @@ Item
                     }
                     else
                     {
-                        Qt.openUrlExternally("https://mycloud.ultimaker.com/app/manage/printers")
+                        Qt.openUrlExternally("https://mycloud.ultimaker.com/")
                     }
 
                 }

+ 11 - 3
tests/API/TestAccount.py

@@ -19,16 +19,24 @@ def test_login():
     account = Account(MagicMock())
     mocked_auth_service = MagicMock()
     account._authorization_service = mocked_auth_service
+    account.logout = MagicMock()
 
     account.login()
-    mocked_auth_service.startAuthorizationFlow.assert_called_once_with()
+    mocked_auth_service.startAuthorizationFlow.assert_called_once_with(False)
 
-    # Fake a sucesfull login
+    # Fake a successful login
     account._onLoginStateChanged(True)
 
     # Attempting to log in again shouldn't change anything.
     account.login()
-    mocked_auth_service.startAuthorizationFlow.assert_called_once_with()
+    mocked_auth_service.startAuthorizationFlow.assert_called_once_with(False)
+
+    # Attempting to log in with force_logout_before_login as True should call the logout before calling the
+    # startAuthorizationFlow(True).
+    account.login(force_logout_before_login=True)
+    account.logout.assert_called_once_with()
+    mocked_auth_service.startAuthorizationFlow.assert_called_with(True)
+    assert mocked_auth_service.startAuthorizationFlow.call_count == 2
 
 
 def test_initialize():

+ 17 - 1
tests/TestOAuth2.py

@@ -7,7 +7,7 @@ from PyQt5.QtGui import QDesktopServices
 
 from UM.Preferences import Preferences
 from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers, TOKEN_TIMESTAMP_FORMAT
-from cura.OAuth2.AuthorizationService import AuthorizationService
+from cura.OAuth2.AuthorizationService import AuthorizationService, MYCLOUD_LOGOFF_URL
 from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer
 from cura.OAuth2.Models import OAuth2Settings, AuthenticationResponse, UserProfile
 
@@ -226,3 +226,19 @@ def test_wrongServerResponses() -> None:
     with patch.object(AuthorizationHelpers, "parseJWT", return_value=UserProfile()):
         authorization_service._onAuthStateChanged(MALFORMED_AUTH_RESPONSE)
     assert authorization_service.getUserProfile() is None
+
+
+def test__generate_auth_url() -> None:
+    preferences = Preferences()
+    authorization_service = AuthorizationService(OAUTH_SETTINGS, preferences)
+    query_parameters_dict = {
+        "client_id": "",
+        "redirect_uri": OAUTH_SETTINGS.CALLBACK_URL,
+        "scope": OAUTH_SETTINGS.CLIENT_SCOPES,
+        "response_type": "code"
+    }
+    auth_url = authorization_service._generate_auth_url(query_parameters_dict, force_browser_logout = False)
+    assert MYCLOUD_LOGOFF_URL + "?next=" not in auth_url
+
+    auth_url = authorization_service._generate_auth_url(query_parameters_dict, force_browser_logout = True)
+    assert MYCLOUD_LOGOFF_URL + "?next=" in auth_url