diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index e46176e63df2..3ac27bb8c09c 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -4,7 +4,7 @@ 25.5 ----- - +* [***] Use web-based sign-in flow (hidden under a feature flag) [#23675] 25.4 ----- diff --git a/WordPress/Classes/Login/WordPressDotComAuthenticator.swift b/WordPress/Classes/Login/WordPressDotComAuthenticator.swift index b143c8d2979a..c0c0830904eb 100644 --- a/WordPress/Classes/Login/WordPressDotComAuthenticator.swift +++ b/WordPress/Classes/Login/WordPressDotComAuthenticator.swift @@ -1,6 +1,7 @@ import AuthenticationServices import Foundation import UIKit +import WordPressAuthenticator import Alamofire @@ -10,6 +11,7 @@ import Alamofire struct WordPressDotComAuthenticator { enum Error: Swift.Error { case invalidCallbackURL + case loginDenied(message: String) case obtainAccessToken case urlError(URLError) case parsing(DecodingError) @@ -17,7 +19,76 @@ struct WordPressDotComAuthenticator { case unknown(Swift.Error) } + @MainActor + func signIn(from viewController: UINavigationController) async { + let token: String + do { + token = try await authenticate(from: viewController) + } catch { + if let error = error as? WordPressDotComAuthenticator.Error { + presentSignInError(error, from: viewController) + } else { + wpAssertionFailure("WP.com web login failed", userInfo: ["error": "\(error)"]) + } + return + } + + let delegate = WordPressAuthenticator.shared.delegate! + let credentials = AuthenticatorCredentials(wpcom: WordPressComCredentials(authToken: token, isJetpackLogin: false, multifactor: false)) + SVProgressHUD.show() + delegate.sync(credentials: credentials) { + SVProgressHUD.dismiss() + + delegate.presentLoginEpilogue( + in: viewController, + for: credentials, + source: .custom(source: "web-login"), + onDismiss: { /* Do nothing */ } + ) + } + } + + private func presentSignInError(_ error: WordPressDotComAuthenticator.Error, from viewController: UIViewController) { + // Show an alert for non-cancellation errors. + let alertMessage: String + switch error { + case .cancelled: + // `.cancelled` error is thrown when user taps the cancel button in the presented Safari view controller. + // No need to show an alert for this error. + return + case let .loginDenied(message): + alertMessage = message + case let .urlError(error): + alertMessage = error.localizedDescription + case .invalidCallbackURL, .obtainAccessToken, .parsing, .unknown: + // These errors are unexpected. + wpAssertionFailure("WP.com web login failed", userInfo: ["error": "\(error)"]) + alertMessage = SharedStrings.Error.generic + } + + let alert = UIAlertController( + title: NSLocalizedString("generic.error.title", value: "Error", comment: "A generic title for an error"), + message: alertMessage, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: SharedStrings.Button.close, style: .cancel, handler: nil)) + viewController.present(alert, animated: true) + } + func authenticate(from viewController: UIViewController) async throws -> String { + WPAnalytics.track(.wpcomWebSignIn, properties: ["stage": "start"]) + + do { + let value = try await _authenticate(from: viewController) + WPAnalytics.track(.wpcomWebSignIn, properties: ["stage": "success"]) + return value + } catch { + WPAnalytics.track(.wpcomWebSignIn, properties: ["stage": "error", "error": "\(error)"]) + throw error + } + } + + private func _authenticate(from viewController: UIViewController) async throws -> String { let clientId = ApiCredentials.client let clientSecret = ApiCredentials.secret let redirectURI = "x-wordpress-app://oauth2-callback" @@ -55,8 +126,17 @@ struct WordPressDotComAuthenticator { clientSecret: String, redirectURI: String ) async throws -> String { - guard let query = URLComponents(url: url, resolvingAgainstBaseURL: true)?.queryItems, - let code = query.first(where: { $0.name == "code" })?.value else { + guard let query = URLComponents(url: url, resolvingAgainstBaseURL: true)?.queryItems else { + throw Error.invalidCallbackURL + } + + let queryMap: [String: String] = query.reduce(into: [:]) { $0[$1.name] = $1.value } + + guard let code = queryMap["code"] else { + if queryMap["error"] == "access_denied" { + let message = NSLocalizedString("wpComLogin.error.accessDenied", value: "Access denied. You need to approve to log in to WordPress.com", comment: "Error message when user denies access to WordPress.com") + throw Error.loginDenied(message: message) + } throw Error.invalidCallbackURL } diff --git a/WordPress/Classes/System/Root View/SplitViewRootPresenter.swift b/WordPress/Classes/System/Root View/SplitViewRootPresenter.swift index 54bb3ee16f0c..1bad8748608a 100644 --- a/WordPress/Classes/System/Root View/SplitViewRootPresenter.swift +++ b/WordPress/Classes/System/Root View/SplitViewRootPresenter.swift @@ -181,18 +181,13 @@ final class SplitViewRootPresenter: RootViewPresenter { } @MainActor private func signIn() async { - WPAnalytics.track(.wpcomWebSignIn, properties: ["source": "sidebar", "stage": "start"]) - let token: String do { token = try await WordPressDotComAuthenticator().authenticate(from: splitVC) } catch { - WPAnalytics.track(.wpcomWebSignIn, properties: ["source": "sidebar", "stage": "error", "error": "\(error)"]) return } - WPAnalytics.track(.wpcomWebSignIn, properties: ["source": "sidebar", "stage": "success"]) - SVProgressHUD.show() let credentials = WordPressComCredentials(authToken: token, isJetpackLogin: false, multifactor: false) WordPressAuthenticator.shared.delegate!.sync(credentials: .init(wpcom: credentials)) { diff --git a/WordPress/Classes/System/Root View/WordPressAuthenticatorProtocol.swift b/WordPress/Classes/System/Root View/WordPressAuthenticatorProtocol.swift index 1d6d49d5a7ed..b540dc828697 100644 --- a/WordPress/Classes/System/Root View/WordPressAuthenticatorProtocol.swift +++ b/WordPress/Classes/System/Root View/WordPressAuthenticatorProtocol.swift @@ -8,6 +8,34 @@ protocol WordPressAuthenticatorProtocol { extension WordPressAuthenticator: WordPressAuthenticatorProtocol { static func loginUI() -> UIViewController? { - Self.loginUI(showCancel: false, restrictToWPCom: false, onLoginButtonTapped: nil) + Self.loginUI(showCancel: false, restrictToWPCom: false, onLoginButtonTapped: nil, continueWithDotCom: { viewController in + guard Self.dotComWebLoginEnabled, let navigationController = viewController.navigationController else { + return false + } + + Task { @MainActor in + await WordPressDotComAuthenticator().signIn(from: navigationController) + } + + return true + }) + } + + static var dotComWebLoginEnabled: Bool { + // Some UI tests go through the native login flow. They should be updated once the web sign in flow is fully + // rolled out. We'll disable web login for UI tests for now. + if UITestConfigurator.isUITesting() { + return false + } + + // TODO: Replce with a remote feature flag. + // Enable web-based login for debug builds until the remote feature flag is available. + #if DEBUG + let webLoginEnabled = true + #else + let webLoginEnabled = false + #endif + + return webLoginEnabled } } diff --git a/WordPress/Classes/System/UITesting/UITestConfigurator.swift b/WordPress/Classes/System/UITesting/UITestConfigurator.swift index 6697beb1c440..5651cf6a8188 100644 --- a/WordPress/Classes/System/UITesting/UITestConfigurator.swift +++ b/WordPress/Classes/System/UITesting/UITestConfigurator.swift @@ -1,6 +1,10 @@ import Foundation struct UITestConfigurator { + static func isUITesting() -> Bool { + CommandLine.arguments.contains("-ui-testing") + } + static func prepareApplicationForUITests(in app: UIApplication, window: UIWindow) { let arguments = CommandLine.arguments if arguments.contains("-ui-testing") { diff --git a/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift b/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift index 3fe9c0400d76..9c2d85dfce97 100644 --- a/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift @@ -525,18 +525,13 @@ class MeViewController: UITableViewController { /// fileprivate func promptForLoginOrSignup() { Task { @MainActor in - WPAnalytics.track(.wpcomWebSignIn, properties: ["source": "me", "stage": "start"]) - let token: String do { token = try await WordPressDotComAuthenticator().authenticate(from: self) } catch { - WPAnalytics.track(.wpcomWebSignIn, properties: ["source": "me", "stage": "error", "error": "\(error)"]) return } - WPAnalytics.track(.wpcomWebSignIn, properties: ["source": "me", "stage": "success"]) - SVProgressHUD.show() let credentials = WordPressComCredentials(authToken: token, isJetpackLogin: false, multifactor: false) WordPressAuthenticator.shared.delegate!.sync(credentials: .init(wpcom: credentials)) { diff --git a/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticator.swift b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticator.swift index c0aa52c97abe..bec3959c8030 100644 --- a/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticator.swift +++ b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticator.swift @@ -165,7 +165,7 @@ import WordPressKit /// - restrictToWPCom: Whether only WordPress.com login is enabled. /// - onLoginButtonTapped: Called when the login button on the prologue screen is tapped. /// - Returns: The root view controller for the login flow. - public class func loginUI(showCancel: Bool = false, restrictToWPCom: Bool = false, onLoginButtonTapped: (() -> Void)? = nil) -> UIViewController? { + public class func loginUI(showCancel: Bool = false, restrictToWPCom: Bool = false, onLoginButtonTapped: (() -> Void)? = nil, continueWithDotCom: ((UIViewController) -> Bool)? = nil) -> UIViewController? { let storyboard = Storyboard.login.instance guard let controller = storyboard.instantiateInitialViewController() else { assertionFailure("Cannot instantiate initial login controller from Login.storyboard") @@ -174,6 +174,7 @@ import WordPressKit if let loginNavController = controller as? LoginNavigationController, let loginPrologueViewController = loginNavController.viewControllers.first as? LoginPrologueViewController { loginPrologueViewController.showCancel = showCancel + loginPrologueViewController.continueWithDotComOverwrite = continueWithDotCom } controller.modalPresentationStyle = .fullScreen diff --git a/WordPressAuthenticator/Sources/Signin/LoginPrologueViewController.swift b/WordPressAuthenticator/Sources/Signin/LoginPrologueViewController.swift index e7c4fe7d30da..e7689088bed1 100644 --- a/WordPressAuthenticator/Sources/Signin/LoginPrologueViewController.swift +++ b/WordPressAuthenticator/Sources/Signin/LoginPrologueViewController.swift @@ -11,6 +11,7 @@ class LoginPrologueViewController: LoginViewController { private var buttonViewController: NUXButtonViewController? private var stackedButtonsViewController: NUXStackedButtonsViewController? var showCancel = false + var continueWithDotComOverwrite: ((UIViewController) -> Bool)? = nil @IBOutlet private weak var buttonContainerView: UIView! /// Blur effect on button container view @@ -526,6 +527,10 @@ class LoginPrologueViewController: LoginViewController { /// Unified "Continue with WordPress.com" prologue button action. /// private func continueWithDotCom() { + if let continueWithDotComOverwrite, continueWithDotComOverwrite(self) { + return + } + guard let vc = GetStartedViewController.instantiate(from: .getStarted) else { WPAuthenticatorLogError("Failed to navigate from LoginPrologueViewController to GetStartedViewController") return