diff --git a/cura/API/Account.py b/cura/API/Account.py index d1fda63d2b..7e8802eddd 100644 --- a/cura/API/Account.py +++ b/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): diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py index 2f865456b6..47e6c139b8 100644 --- a/cura/OAuth2/AuthorizationService.py +++ b/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: diff --git a/resources/qml/WelcomePages/AddCloudPrintersView.qml b/resources/qml/WelcomePages/AddCloudPrintersView.qml index f97d68f776..ccaaa6908f 100644 --- a/resources/qml/WelcomePages/AddCloudPrintersView.qml +++ b/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 } diff --git a/resources/qml/WelcomePages/AddNetworkOrLocalPrinterContent.qml b/resources/qml/WelcomePages/AddNetworkOrLocalPrinterContent.qml index 9e892e5521..b6f715aa0b 100644 --- a/resources/qml/WelcomePages/AddNetworkOrLocalPrinterContent.qml +++ b/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/") } } diff --git a/tests/API/TestAccount.py b/tests/API/TestAccount.py index 4c6141e782..09091ba7e0 100644 --- a/tests/API/TestAccount.py +++ b/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(): diff --git a/tests/TestOAuth2.py b/tests/TestOAuth2.py index 1e305c6549..6b7e28917f 100644 --- a/tests/TestOAuth2.py +++ b/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