Repository: woocommerce/woocommerce-ios
Pull request #15491: [Jetpack Setup] Use magic link for login when detecting suspicious emails
Author: login: hichamboushaba
Labels: name: type: enhancement
description: A request for an enhancement.
name: feature: REST API
description: Authenticating requests using application password and using REST API instead of Jetpack tunnel.
Created: 2025-04-05T17:27:34Z | Merged: 2025-04-08T10:52:58Z

<!-- Remember about a good descriptive title. -->

Closes: #15443
<!-- Id number of the GitHub issue this PR addresses. -->
<!-- Part of: # -> use this if your PR is one of many related to one issue -->

<!-- 
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🛠 Need an AI review?  
Add a comment: `@coderabbitai review`
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-->

## Description
This PR improves the login of users with flagged emails during Jetpack Setup by using magic link by default, this is achieved by:

- Adding a new screen that allows requesting a magic link manually.
- The screen allows the user to switch to username/password login.
- The `WPComEmailLoginView` is updated to support a `usernameOnly` mode and reused when the user wants to use their username.

## Steps to reproduce
For all tests, please sign in using Application Passwords.

TC1: Confirming navigation to magic link request screen
1. Start Jetpack Setup from the dashboard or the app settings.
2. Enter the email address shared here 37602-pb
3. Make sure not to send the magic link.

For all the following TCs, apply the following [~~patch~~](https://github.com/user-attachments/files/19617690/patch.patch) (edit: correct [patch](https://github.com/user-attachments/files/19632878/patch.patch))

TC2: Magic Link login flow
1. Start Jetpack Setup from the dashboard or the app settings.
2. Enter any email address
3. Tap on Send email in the next screen.

TC3: Fallback to username/password
1. Start Jetpack Setup from the dashboard or the app settings.
2. Enter any email address
3. Tap on Use username and password instead.
4. Sign in using WordPress.com credentials..

## Testing information
TC1: Confirm that the app shows the Magic Link request screen.

TC2: Confirm that the email was sent.

TC3: Confirm that signing using WordPress.com credentials work

## Screenshots

https://github.com/user-attachments/assets/a931e962-151c-4928-a30e-ba1a918d1280


---
- [ ] I have considered if this change warrants user-facing release notes and have added them to `RELEASE-NOTES.txt` if necessary.

## Reviewer (or Author, in the case of optional code reviews):

Please make sure these conditions are met before approving the PR, or request changes if the PR needs improvement:

- [ ] The PR is small and has a clear, single focus, or a valid explanation is provided in the description. If needed, please request to split it into smaller PRs.
- [ ] Ensure Adequate Unit Test Coverage: The changes are reasonably covered by unit tests or an explanation is provided in the PR description.
- [ ] Manual Testing: The author listed all the tests they ran, including smoke tests when needed (e.g., for refactorings). The reviewer confirmed that the PR works as expected on all devices (phone/tablet) and no regressions are added.


Diff:
diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt
index 0abc1703c59..7a8dd62b75c 100644
--- a/RELEASE-NOTES.txt
+++ b/RELEASE-NOTES.txt
@@ -3,7 +3,7 @@
 
 22.2
 -----
-
+- [*] [Internal] Improved login flow during Jetpack Setup for accounts using emails marked as suspicious [https://github.com/woocommerce/woocommerce-ios/pull/15491]
 
 22.1
 -----
diff --git a/WooCommerce/Classes/Authentication/WPComLogin/WPComEmailLoginView.swift b/WooCommerce/Classes/Authentication/WPComLogin/WPComEmailLoginView.swift
index 8ccffa84e69..098758be8fc 100644
--- a/WooCommerce/Classes/Authentication/WPComLogin/WPComEmailLoginView.swift
+++ b/WooCommerce/Classes/Authentication/WPComLogin/WPComEmailLoginView.swift
@@ -73,10 +73,10 @@ struct WPComEmailLoginView: View {
 
                 // Email field
                 AuthenticationFormFieldView(viewModel: .init(
-                    header: Localization.emailLabel,
-                    placeholder: Localization.enterEmail,
-                    keyboardType: .emailAddress,
-                    text: $viewModel.emailAddress,
+                    header: viewModel.usernameOnly ? Localization.usernameLabel : Localization.emailLabel,
+                    placeholder: viewModel.usernameOnly ? Localization.enterUsername : Localization.enterEmail,
+                    keyboardType: viewModel.usernameOnly ? .default : .emailAddress,
+                    text: $viewModel.emailOrUsername,
                     isSecure: false,
                     errorMessage: nil,
                     isFocused: isEmailFieldFocused,
@@ -84,7 +84,8 @@ struct WPComEmailLoginView: View {
                 ))
                 .focused($isEmailFieldFocused)
 
-                if viewModel.allowAccountCreation {
+                if viewModel.allowAccountCreation,
+                   !viewModel.usernameOnly {
                     // Account creation hint
                     Text(Localization.accountCreationHint)
                         .footnoteStyle()
@@ -101,12 +102,12 @@ struct WPComEmailLoginView: View {
                     ServiceLocator.analytics.track(event: .JetpackSetup.loginFlow(step: .emailAddress, tap: .submit))
                     Task { @MainActor in
                         isPrimaryButtonLoading = true
-                        await viewModel.checkWordPressComAccount(email: viewModel.emailAddress)
+                        await viewModel.checkWordPressComAccount(emailOrUsername: viewModel.emailOrUsername)
                         isPrimaryButtonLoading = false
                     }
                 }
                 .buttonStyle(PrimaryLoadingButtonStyle(isLoading: isPrimaryButtonLoading))
-                .disabled(viewModel.emailAddress.isEmpty)
+                .disabled(viewModel.emailOrUsername.isEmpty)
 
                 // Terms label
                 AttributedText(viewModel.termsAttributedString)
@@ -129,10 +130,20 @@ private extension WPComEmailLoginView {
             "Email Address or Username",
             comment: "Label for the email field on the WPCom email login screen of the Jetpack setup flow."
         )
+        static let usernameLabel = NSLocalizedString(
+            "wpComEmailLoginView.usernameLabel",
+            value: "Username",
+            comment: "Label for the username field on the WPCom email login screen of the Jetpack setup flow."
+        )
         static let enterEmail = NSLocalizedString(
             "Enter email or username",
             comment: "Placeholder text for the email field on the WPCom email login screen of the Jetpack setup flow."
         )
+        static let enterUsername = NSLocalizedString(
+            "wpComEmailLoginView.enterUsername",
+            value: "Enter username",
+            comment: "Placeholder text for the username field on the WPCom email login screen of the Jetpack setup flow."
+        )
         static let accountCreationHint = NSLocalizedString(
             "wpComEmailLoginView.accountCreationHint",
             value: "If you don't have an account, we'll use this email to create one.",
@@ -148,7 +159,8 @@ struct WPComEmailLoginView_Previews: PreviewProvider {
                                              requiresConnectionOnly: true,
                                              allowAccountCreation: false,
                                              onPasswordUIRequest: { _ in },
-                                             onMagicLinkUIRequest: { _, _ in },
+                                             onMagicLinkRequest: { _ in },
+                                             onMagicLinkSent: { _, _ in },
                                              onError: { _ in }))
     }
 }
diff --git a/WooCommerce/Classes/Authentication/WPComLogin/WPComEmailLoginViewModel.swift b/WooCommerce/Classes/Authentication/WPComLogin/WPComEmailLoginViewModel.swift
index 9344ed23869..bcc778a836f 100644
--- a/WooCommerce/Classes/Authentication/WPComLogin/WPComEmailLoginViewModel.swift
+++ b/WooCommerce/Classes/Authentication/WPComLogin/WPComEmailLoginViewModel.swift
@@ -21,7 +21,8 @@ final class WPComEmailLoginViewModel: ObservableObject {
     let titleString: String
     let subtitleString: String
 
-    @Published var emailAddress: String = ""
+    @Published var emailOrUsername: String = ""
+    @Published var usernameOnly: Bool = false
 
     let termsAttributedString: NSAttributedString
 
@@ -29,7 +30,8 @@ final class WPComEmailLoginViewModel: ObservableObject {
     private let accountService: WordPressComAccountServiceProtocol
     private let analytics: Analytics
     private let onPasswordUIRequest: (String) -> Void
-    private let onMagicLinkUIRequest: (_ email: String, _ isSignup: Bool) -> Void
+    private let onMagicLinkRequest: (String) -> Void
+    private let onMagicLinkSent: (_ email: String, _ isSignup: Bool) -> Void
     private let onError: (String) -> Void
 
     private var emailFieldSubscription: AnyCancellable?
@@ -41,13 +43,15 @@ final class WPComEmailLoginViewModel: ObservableObject {
          accountService: WordPressComAccountServiceProtocol = WordPressComAccountService(),
          analytics: Analytics = ServiceLocator.analytics,
          onPasswordUIRequest: @escaping (String) -> Void,
-         onMagicLinkUIRequest: @escaping (String, Bool) -> Void,
+         onMagicLinkRequest: @escaping (String) -> Void,
+         onMagicLinkSent: @escaping (String, Bool) -> Void,
          onError: @escaping (String) -> Void) {
         self.allowAccountCreation = allowAccountCreation
         self.analytics = analytics
         self.accountService = accountService
         self.onPasswordUIRequest = onPasswordUIRequest
-        self.onMagicLinkUIRequest = onMagicLinkUIRequest
+        self.onMagicLinkRequest = onMagicLinkRequest
+        self.onMagicLinkSent = onMagicLinkSent
         self.onError = onError
 
         self.titleString = requiresConnectionOnly ? Localization.connectJetpack : Localization.installJetpack
@@ -73,43 +77,48 @@ final class WPComEmailLoginViewModel: ObservableObject {
     }
 
     @MainActor
-    func checkWordPressComAccount(email: String) async {
+    func checkWordPressComAccount(emailOrUsername: String) async {
         do {
             let passwordless = try await withCheckedThrowingContinuation { continuation in
-                accountService.isPasswordlessAccount(username: email, success: { passwordless in
+                accountService.isPasswordlessAccount(username: emailOrUsername, success: { passwordless in
                     continuation.resume(returning: passwordless)
                 }, failure: { error in
                     DDLogError("⛔️ Error checking for passwordless account: \(error)")
                     continuation.resume(throwing: error)
                 })
             }
-            await startAuthentication(email: email, isPasswordlessAccount: passwordless)
+            await startAuthentication(emailOrUsername: emailOrUsername, isPasswordlessAccount: passwordless)
         } catch {
-            guard allowAccountCreation,
-                  let apiError = error as? WordPressAPIError<WordPressComRestApiEndpointError>,
-                  case .endpointError(let endpointError) = apiError,
-                  endpointError.apiErrorCode == Constants.unknownUserErrorCode else {
-                analytics.track(event: .JetpackSetup.loginFlow(step: .emailAddress, failure: error))
-                onError(error.localizedDescription)
+            let apiErrorCode: String? = {
+                if let apiError = error as? WordPressAPIError<WordPressComRestApiEndpointError>,
+                   case .endpointError(let endpointError) = apiError {
+                    return endpointError.apiErrorCode
+                }
+                return nil
+            }()
+
+            if allowAccountCreation,
+               apiErrorCode == Constants.unknownUserErrorCode {
+                await handleUnkownUserError(emailOrUsername: emailOrUsername, error: error)
                 return
             }
 
-            guard email.isValidEmail() else {
-                analytics.track(event: .JetpackSetup.loginFlow(step: .emailAddress, failure: error))
-                onError(Localization.unknownUsername)
+            if apiErrorCode == Constants.emailNotAllowed {
+                onMagicLinkRequest(emailOrUsername)
                 return
             }
 
-            await requestAuthenticationLink(email: email, forAccountCreation: true)
+            analytics.track(event: .JetpackSetup.loginFlow(step: .emailAddress, failure: error))
+            onError(error.localizedDescription)
         }
     }
 
     @MainActor
-    private func startAuthentication(email: String, isPasswordlessAccount: Bool) async {
+    private func startAuthentication(emailOrUsername: String, isPasswordlessAccount: Bool) async {
         if isPasswordlessAccount {
-            await requestAuthenticationLink(email: email)
+            await requestAuthenticationLink(email: emailOrUsername)
         } else {
-            onPasswordUIRequest(email)
+            onPasswordUIRequest(emailOrUsername)
         }
     }
 
@@ -126,7 +135,7 @@ final class WPComEmailLoginViewModel: ObservableObject {
                     continuation.resume(throwing: error)
                 })
             }
-            onMagicLinkUIRequest(email, forAccountCreation)
+            onMagicLinkSent(email, forAccountCreation)
         } catch {
             onError(error.localizedDescription)
             analytics.track(event: .JetpackSetup.loginFlow(step: .emailAddress, isSignup: forAccountCreation, failure: error))
@@ -134,6 +143,19 @@ final class WPComEmailLoginViewModel: ObservableObject {
     }
 }
 
+private extension WPComEmailLoginViewModel {
+    @MainActor
+    func handleUnkownUserError(emailOrUsername: String, error: Error) async {
+        guard emailOrUsername.isValidEmail() else {
+            analytics.track(event: .JetpackSetup.loginFlow(step: .emailAddress, failure: error))
+            onError(Localization.unknownUsername)
+            return
+        }
+
+        await requestAuthenticationLink(email: emailOrUsername, forAccountCreation: true)
+    }
+}
+
 extension WPComEmailLoginViewModel {
     private enum Constants {
         static let fieldDebounceDuration = 0.3
@@ -141,6 +163,7 @@ extension WPComEmailLoginViewModel {
         static let jetpackShareDetailsURL = "https://jetpack.com/redirect/?source=jetpack-support-what-data-does-jetpack-sync&site="
         static let wpcomErrorCodeKey = "WordPressComRestApiErrorCodeKey"
         static let unknownUserErrorCode = "unknown_user"
+        static let emailNotAllowed = "email_login_not_allowed"
     }
 
     enum Localization {
diff --git a/WooCommerce/Classes/Authentication/WPComLogin/WPComLoginGravatarView.swift b/WooCommerce/Classes/Authentication/WPComLogin/WPComLoginGravatarView.swift
new file mode 100644
index 00000000000..9e202080508
--- /dev/null
+++ b/WooCommerce/Classes/Authentication/WPComLogin/WPComLoginGravatarView.swift
@@ -0,0 +1,40 @@
+import SwiftUI
+import Kingfisher
+
+struct WPComLoginGravatarView: View {
+    let email: String
+    let gravatarURL: URL?
+
+    var body: some View {
+        HStack(spacing: Constants.spacing) {
+            KFImage(gravatarURL)
+                .resizable()
+                .clipShape(Circle())
+                .frame(width: Constants.avatarSize, height: Constants.avatarSize)
+
+            Text(email)
+
+            Spacer()
+        }
+        .padding(Constants.avatarPadding)
+        .background(
+            RoundedRectangle(cornerRadius: 8, style: .continuous)
+                .strokeBorder(.gray, lineWidth: 1)
+        )
+    }
+}
+
+private extension WPComLoginGravatarView {
+    enum Constants {
+        static let spacing: CGFloat = 16
+        static let avatarSize: CGFloat = 32
+        static let avatarPadding: EdgeInsets = .init(top: 8, leading: 16, bottom: 8, trailing: 16)
+    }
+}
+
+#Preview {
+    WPComLoginGravatarView(
+        email: "example@example.com",
+        gravatarURL: URL(string: "https://www.gravatar.com/avatar/example")
+    )
+}
diff --git a/WooCommerce/Classes/Authentication/WPComLogin/WPComMagicLinkRequestView.swift b/WooCommerce/Classes/Authentication/WPComLogin/WPComMagicLinkRequestView.swift
new file mode 100644
index 00000000000..c3ff7ae9a8d
--- /dev/null
+++ b/WooCommerce/Classes/Authentication/WPComLogin/WPComMagicLinkRequestView.swift
@@ -0,0 +1,109 @@
+import SwiftUI
+import Kingfisher
+
+final class WPComMagicLinkRequestHostingController: UIHostingController<WPComMagicLinkRequestView> {
+    @available(*, unavailable)
+    required dynamic init?(coder aDecoder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    init(title: String,
+         viewModel: WPComMagicLinkRequestViewModel) {
+        let view = WPComMagicLinkRequestView(title: title,
+                                             viewModel: viewModel)
+        super.init(rootView: view)
+    }
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        configureTransparentNavigationBar()
+    }
+}
+
+struct WPComMagicLinkRequestView: View {
+    @StateObject private var viewModel: WPComMagicLinkRequestViewModel
+
+    /// Title to display at the top of the view.
+    private let title: String
+
+    init(title: String,
+         viewModel: WPComMagicLinkRequestViewModel) {
+        self.title = title
+        self._viewModel = StateObject(wrappedValue: viewModel)
+    }
+
+    var body: some View {
+        ScrollView {
+            VStack(alignment: .leading, spacing: Constants.blockVerticalPadding) {
+                JetpackInstallHeaderView()
+
+                // Title
+                Text(title)
+                    .largeTitleStyle()
+
+                // Avatar and email
+                WPComLoginGravatarView(email: viewModel.email, gravatarURL: viewModel.avatarURL)
+
+                Text(Localization.label)
+                    .bodyStyle()
+
+                Spacer()
+            }
+            .padding(Constants.contentPadding)
+        }.safeAreaInset(edge: .bottom) {
+            VStack {
+                Button(Localization.sendLinkButton) {
+                    Task {
+                        await viewModel.sendMagicLink()
+                    }
+                }
+                .buttonStyle(PrimaryLoadingButtonStyle(isLoading: viewModel.isLoading))
+
+                Button(Localization.fallbackButton) {
+                    viewModel.useUsernamePassword()
+                }
+                .buttonStyle(SecondaryButtonStyle())
+            }
+            .padding(Constants.contentPadding)
+            .background(Color(uiColor: .systemBackground))
+        }
+    }
+}
+
+private extension WPComMagicLinkRequestView {
+    enum Constants {
+        static let blockVerticalPadding: CGFloat = 32
+        static let contentVerticalSpacing: CGFloat = 8
+        static let contentPadding: CGFloat = 16
+    }
+
+    enum Localization {
+        static let label = NSLocalizedString(
+            "wpcomMagicLinkRequestView.label",
+            value: "We'll email you a link that'll log you in instantly, no password needed.",
+            comment: "A message that informs the user that a magic link will be sent to their email address."
+        )
+        static let sendLinkButton = NSLocalizedString(
+            "wpcomMagicLinkRequestView.primaryAction",
+            value: "Send link by email",
+            comment: "Button title for the action of sending a magic link email to log in to WordPress.com."
+        )
+        // For now this is hardcoded for the username/password fallback, if we need to support email/password in the future,
+        // we can add a  way to configure the fallback action.
+        static let fallbackButton = NSLocalizedString(
+            "wpcomMagicLinkRequestView.useUsernamePassword",
+            value: "Use username and password",
+            comment: "Button title for the action of signing in using WordPress.com username and password."
+        )
+    }
+}
+
+
+#Preview {
+    WPComMagicLinkRequestView(title: "Connect Jetpack",
+                              viewModel: WPComMagicLinkRequestViewModel(email: "test@example.com",
+                                                                        onMagicLinkSent: { _ in },
+                                                                        onUseUsernamePassword: {},
+                                                                        onError: { _ in })
+    )
+}
diff --git a/WooCommerce/Classes/Authentication/WPComLogin/WPComMagicLinkRequestViewModel.swift b/WooCommerce/Classes/Authentication/WPComLogin/WPComMagicLinkRequestViewModel.swift
new file mode 100644
index 00000000000..53fbe33fb69
--- /dev/null
+++ b/WooCommerce/Classes/Authentication/WPComLogin/WPComMagicLinkRequestViewModel.swift
@@ -0,0 +1,68 @@
+import Foundation
+import WooFoundation
+import WordPressAuthenticator
+import WordPressUI
+
+final class WPComMagicLinkRequestViewModel: ObservableObject {
+    let email: String
+
+    @Published private(set) var isLoading = false
+    let avatarURL: URL?
+
+    private let onMagicLinkSent: (String) -> Void
+    private let onUseUsernamePassword: () -> Void
+    private let onError: (String) -> Void
+
+    private let accountService: WordPressComAccountServiceProtocol
+    private let analytics: Analytics
+
+    init(email: String,
+         onMagicLinkSent: @escaping (String) -> Void,
+         onUseUsernamePassword: @escaping () -> Void,
+         onError: @escaping (String) -> Void,
+         accountService: WordPressComAccountServiceProtocol = WordPressComAccountService(),
+         analytics: Analytics = ServiceLocator.analytics) {
+        self.email = email
+        self.avatarURL = Gravatar.gravatarUrl(for: email, defaultImage: .mp)
+
+        self.onMagicLinkSent = onMagicLinkSent
+        self.onUseUsernamePassword = onUseUsernamePassword
+        self.onError = onError
+
+        self.accountService = accountService
+        self.analytics = analytics
+    }
+
+    @MainActor
+    func sendMagicLink() async {
+        isLoading = true
+        await handleSendingMagicLink()
+        isLoading = false
+    }
+
+    func useUsernamePassword() {
+        onUseUsernamePassword()
+    }
+}
+
+private extension WPComMagicLinkRequestViewModel {
+    @MainActor
+    func handleSendingMagicLink() async {
+        do {
+            try await withCheckedThrowingContinuation { continuation in
+                accountService.requestAuthenticationLink(for: email,
+                                                         jetpackLogin: false,
+                                                         createAccountIfNotFound: false,
+                                                         success: {
+                    continuation.resume()
+                }, failure: { error in
+                    continuation.resume(throwing: error)
+                })
+            }
+            onMagicLinkSent(email)
+        } catch {
+            onError(error.localizedDescription)
+            analytics.track(event: .JetpackSetup.loginFlow(step: .magicLink, failure: error))
+        }
+    }
+}
diff --git a/WooCommerce/Classes/Authentication/WPComLogin/WPComPasswordLoginView.swift b/WooCommerce/Classes/Authentication/WPComLogin/WPComPasswordLoginView.swift
index cd523d762b4..a1d47d425af 100644
--- a/WooCommerce/Classes/Authentication/WPComLogin/WPComPasswordLoginView.swift
+++ b/WooCommerce/Classes/Authentication/WPComLogin/WPComPasswordLoginView.swift
@@ -66,21 +66,7 @@ struct WPComPasswordLoginView: View {
                     .largeTitleStyle()
 
                 // Avatar and email
-                HStack(spacing: Constants.contentPadding) {
-                    viewModel.avatarURL.map { url in
-                        KFImage(url)
-                            .resizable()
-                            .clipShape(Circle())
-                            .frame(width: Constants.avatarSize, height: Constants.avatarSize)
-                    }
-                    Text(viewModel.email)
-                    Spacer()
-                }
-                .padding(Constants.avatarPadding)
-                .background(
-                    RoundedRectangle(cornerRadius: 8, style: .continuous)
-                        .strokeBorder(.gray, lineWidth: 1)
-                )
+                WPComLoginGravatarView(email: viewModel.email, gravatarURL: viewModel.avatarURL)
 
                 // Password field
                 AuthenticationFormFieldView(viewModel: .init(
@@ -141,8 +127,6 @@ private extension WPComPasswordLoginView {
         static let blockVerticalPadding: CGFloat = 32
         static let contentVerticalSpacing: CGFloat = 8
         static let contentPadding: CGFloat = 16
-        static let avatarSize: CGFloat = 32
-        static let avatarPadding: EdgeInsets = .init(top: 8, leading: 16, bottom: 8, trailing: 16)
     }
 
     enum Localization {
diff --git a/WooCommerce/Classes/ViewRelated/JetpackSetup/JetpackSetupCoordinator.swift b/WooCommerce/Classes/ViewRelated/JetpackSetup/JetpackSetupCoordinator.swift
index 6bc0de9f5d3..1f3d8430ad8 100644
--- a/WooCommerce/Classes/ViewRelated/JetpackSetup/JetpackSetupCoordinator.swift
+++ b/WooCommerce/Classes/ViewRelated/JetpackSetup/JetpackSetupCoordinator.swift
@@ -30,7 +30,8 @@ final class JetpackSetupCoordinator {
               allowAccountCreation: true,
               accountService: accountService,
               onPasswordUIRequest: showPasswordUI(email:),
-              onMagicLinkUIRequest: showMagicLinkUI,
+              onMagicLinkRequest: showMagicLinkRequestUI,
+              onMagicLinkSent: showMagicLinkSentUI,
               onError: { [weak self] message in
             self?.showAlert(message: message)
         })
@@ -91,11 +92,11 @@ final class JetpackSetupCoordinator {
         return true
     }
 
-    func startAuthentication(with email: String?) {
-        if let email {
+    func startAuthentication(with emailOrUsername: String?) {
+        if let emailOrUsername {
             Task { @MainActor in
                 analytics.track(event: .JetpackSetup.loginFlow(step: .emailAddress))
-                await emailLoginViewModel.checkWordPressComAccount(email: email)
+                await emailLoginViewModel.checkWordPressComAccount(emailOrUsername: emailOrUsername)
             }
         } else {
             showWPComEmailLogin()
@@ -361,11 +362,38 @@ private extension JetpackSetupCoordinator {
 
     func showWPComEmailLogin() {
         analytics.track(event: .JetpackSetup.loginFlow(step: .emailAddress))
+        emailLoginViewModel.usernameOnly = false
         let emailLoginController = WPComEmailLoginHostingController(viewModel: emailLoginViewModel)
         pushOrInitLoginViewController(emailLoginController)
     }
 
-    func showMagicLinkUI(email: String, isSignup: Bool) {
+    func showWPComUsernameLogin() {
+        emailLoginViewModel.usernameOnly = true
+        emailLoginViewModel.emailOrUsername = ""
+
+        let emailViewController = loginNavigationController?.viewControllers.first(where: { $0 is WPComEmailLoginHostingController })
+        if let emailViewController {
+            loginNavigationController?.popToViewController(emailViewController, animated: true)
+        } else {
+            loginNavigationController?.dismiss(animated: true, completion: nil)
+            pushOrInitLoginViewController(WPComEmailLoginHostingController(viewModel: emailLoginViewModel))
+        }
+    }
+
+    func showMagicLinkRequestUI(email: String) {
+        let magicLinkRequestController = WPComMagicLinkRequestHostingController(title: loginViewTitle,
+                                                                                viewModel: .init(email: email,
+                                                                                                 onMagicLinkSent: { [weak self] email in
+            self?.showMagicLinkSentUI(email: email, isSignup: false)
+        },
+                                                                                                 onUseUsernamePassword: showWPComUsernameLogin,
+                                                                                                 onError: { [weak self] message in
+            self?.showAlert(message: message)
+        }))
+        pushOrInitLoginViewController(magicLinkRequestController)
+    }
+
+    func showMagicLinkSentUI(email: String, isSignup: Bool) {
         analytics.track(event: .JetpackSetup.loginFlow(step: .magicLink, isSignup: isSignup))
         let viewController = WPComMagicLinkHostingController(email: email,
                                                              title: loginViewTitle,
diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj
index edb68b001fe..b97e00cb6b5 100644
--- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj
+++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj
@@ -1814,6 +1814,10 @@
 		93FA787221CD2A1A00B663E5 /* CurrencySettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93FA787121CD2A1A00B663E5 /* CurrencySettingsTests.swift */; };
 		953728F82B23635300FDF1D1 /* UIAlertController+helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 953728F72B23635300FDF1D1 /* UIAlertController+helpers.swift */; };
 		95968A512CD109D6001F0DA1 /* CustomFieldsListTopBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95968A502CD109D6001F0DA1 /* CustomFieldsListTopBanner.swift */; };
+		95B6C60C2D9DA5E900E1A661 /* WPComMagicLinkRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B6C60B2D9DA5E900E1A661 /* WPComMagicLinkRequestView.swift */; };
+		95B6C60E2D9DA99200E1A661 /* WPComMagicLinkRequestViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B6C60D2D9DA99200E1A661 /* WPComMagicLinkRequestViewModel.swift */; };
+		95B6C6102D9DADAA00E1A661 /* WPComLoginGravatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B6C60F2D9DAD9600E1A661 /* WPComLoginGravatarView.swift */; };
+		95B6C6142DA18E3A00E1A661 /* WPComMagicLinkRequestViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B6C6132DA18E3A00E1A661 /* WPComMagicLinkRequestViewModelTests.swift */; };
 		A650BE862578E76600C655E0 /* MockStorageManager+Sample.swift in Sources */ = {isa = PBXBuildFile; fileRef = A650BE842578E76600C655E0 /* MockStorageManager+Sample.swift */; };
 		A650BE872578E76600C655E0 /* MockStorageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A650BE852578E76600C655E0 /* MockStorageManager.swift */; };
 		A6557218258B7510008AE7CA /* OrderListCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6557217258B7510008AE7CA /* OrderListCellViewModel.swift */; };
@@ -5009,6 +5013,10 @@
 		93FA787121CD2A1A00B663E5 /* CurrencySettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencySettingsTests.swift; sourceTree = "<group>"; };
 		953728F72B23635300FDF1D1 /* UIAlertController+helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIAlertController+helpers.swift"; sourceTree = "<group>"; };
 		95968A502CD109D6001F0DA1 /* CustomFieldsListTopBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomFieldsListTopBanner.swift; sourceTree = "<group>"; };
+		95B6C60B2D9DA5E900E1A661 /* WPComMagicLinkRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WPComMagicLinkRequestView.swift; sourceTree = "<group>"; };
+		95B6C60D2D9DA99200E1A661 /* WPComMagicLinkRequestViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WPComMagicLinkRequestViewModel.swift; sourceTree = "<group>"; };
+		95B6C60F2D9DAD9600E1A661 /* WPComLoginGravatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WPComLoginGravatarView.swift; sourceTree = "<group>"; };
+		95B6C6132DA18E3A00E1A661 /* WPComMagicLinkRequestViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WPComMagicLinkRequestViewModelTests.swift; sourceTree = "<group>"; };
 		9D2992FEF3D1246B8CCC2EBB /* Pods-WooCommerceTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WooCommerceTests.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-WooCommerceTests/Pods-WooCommerceTests.debug.xcconfig"; sourceTree = "<group>"; };
 		A650BE842578E76600C655E0 /* MockStorageManager+Sample.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "MockStorageManager+Sample.swift"; path = "../../../Yosemite/YosemiteTests/Mocks/MockStorageManager+Sample.swift"; sourceTree = "<group>"; };
 		A650BE852578E76600C655E0 /* MockStorageManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MockStorageManager.swift; path = ../../../Yosemite/YosemiteTests/Mocks/MockStorageManager.swift; sourceTree = "<group>"; };
@@ -13357,6 +13365,7 @@
 		DE63115D2AF1E16500587641 /* WPComLogin */ = {
 			isa = PBXGroup;
 			children = (
+				95B6C60F2D9DAD9600E1A661 /* WPComLoginGravatarView.swift */,
 				DEF8CF1C29AC84BC00800A60 /* WPComEmailLoginView.swift */,
 				DEF8CF1E29AC870A00800A60 /* WPComEmailLoginViewModel.swift */,
 				DE4D239B29B06642003A4B5D /* WPComMagicLinkView.swift */,
@@ -13366,6 +13375,8 @@
 				DE4D23A929B1B0CA003A4B5D /* WPCom2FALoginView.swift */,
 				DE4D23AD29B1B0EF003A4B5D /* WPCom2FALoginViewModel.swift */,
 				DE63115A2AF1E13200587641 /* WPComLoginCoordinator.swift */,
+				95B6C60B2D9DA5E900E1A661 /* WPComMagicLinkRequestView.swift */,
+				95B6C60D2D9DA99200E1A661 /* WPComMagicLinkRequestViewModel.swift */,
 			);
 			path = WPComLogin;
 			sourceTree = "<group>";
@@ -13736,6 +13747,7 @@
 				DE4D239F29B09D71003A4B5D /* WPComMagicLinkViewModelTests.swift */,
 				DE4D23A729B0D11E003A4B5D /* WPComPasswordLoginViewModelTests.swift */,
 				DE4D23AF29B1D02A003A4B5D /* WPCom2FALoginViewModelTests.swift */,
+				95B6C6132DA18E3A00E1A661 /* WPComMagicLinkRequestViewModelTests.swift */,
 			);
 			path = WPComLogin;
 			sourceTree = "<group>";
@@ -16681,6 +16693,7 @@
 				DE279BA826E9C8E3002BA963 /* ShippingLabelSinglePackage.swift in Sources */,
 				01AAD8142D92E37A0081D60B /* PointOfSaleOrderSyncCouponsErrorMessageView.swift in Sources */,
 				01F42C162CE34AB8003D0A5A /* CardPresentModalTapToPaySuccessEmailSent.swift in Sources */,
+				95B6C6102D9DADAA00E1A661 /* WPComLoginGravatarView.swift in Sources */,
 				028FF8E32AA1E1C60038964F /* ProductDetailsCellViewModel+AddOns.swift in Sources */,
 				DEC2962726C17AD8005A056B /* ShippingLabelCustomsForm+Localization.swift in Sources */,
 				26A630FE253F63C300CBC3B1 /* RefundableOrderItem.swift in Sources */,
@@ -16969,6 +16982,7 @@
 				2667BFEB2535FF09008099D4 /* RefundShippingCalculationUseCase.swift in Sources */,
 				B6C838DE28793B3A003AB786 /* CustomFieldViewModel.swift in Sources */,
 				DE65C1F72C48E7DC003EF8D1 /* SupportButton.swift in Sources */,
+				95B6C60E2D9DA99200E1A661 /* WPComMagicLinkRequestViewModel.swift in Sources */,
 				20D210BE2B14C9B90099E517 /* WooPaymentsPayoutStatusDisplayDetails.swift in Sources */,
 				026225212C21F01F00700977 /* PointOfSaleCardPresentPaymentReaderUpdateFailedNonRetryableAlertViewModel.swift in Sources */,
 				E1E125AA26EB42530068A9B0 /* CardPresentModalUpdateProgress.swift in Sources */,
@@ -17271,6 +17285,7 @@
 				262C922126F1370000011F92 /* StorePickerError.swift in Sources */,
 				4515C89825D6C00E0099C8E3 /* ShippingLabelAddressFormViewModel.swift in Sources */,
 				20AE33C72B0510D200527B60 /* HubMenuDestination.swift in Sources */,
+				95B6C60C2D9DA5E900E1A661 /* WPComMagicLinkRequestView.swift in Sources */,
 				CE24BCCF212DE8A6001CD12E /* HeadlineLabelTableViewCell.swift in Sources */,
 				0245465D24EE779D004F531C /* ProductFormEventLogger.swift in Sources */,
 				011DF3442C53A5CF000AFDD9 /* PointOfSaleCardPresentPaymentValidatingOrderMessageViewModel.swift in Sources */,
@@ -17946,6 +17961,7 @@
 				011D39712D0A324200DB1445 /* LocationServiceTests.swift in Sources */,
 				028E1F722833E954001F8829 /* DashboardViewModelTests.swift in Sources */,
 				02BC5AA424D27F8900C43326 /* ProductVariationFormViewModel+ObservablesTests.swift in Sources */,
+				95B6C6142DA18E3A00E1A661 /* WPComMagicLinkRequestViewModelTests.swift in Sources */,
 				CCCC5B1326CC2B9F0034FB63 /* ShippingLabelCustomPackageFormViewModelTests.swift in Sources */,
 				D41C9F3126D9A43200993558 /* WhatsNewViewModelTests.swift in Sources */,
 				02A275BE23FE57DC005C560F /* ProductUIImageLoaderTests.swift in Sources */,
diff --git a/WooCommerce/WooCommerceTests/ViewRelated/JetpackSetup/WPComLogin/WPComEmailLoginViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/JetpackSetup/WPComLogin/WPComEmailLoginViewModelTests.swift
index bcb8eda596f..8069ba0ce59 100644
--- a/WooCommerce/WooCommerceTests/ViewRelated/JetpackSetup/WPComLogin/WPComEmailLoginViewModelTests.swift
+++ b/WooCommerce/WooCommerceTests/ViewRelated/JetpackSetup/WPComLogin/WPComEmailLoginViewModelTests.swift
@@ -11,7 +11,8 @@ final class WPComEmailLoginViewModelTests: XCTestCase {
                                                  requiresConnectionOnly: false,
                                                  allowAccountCreation: false,
                                                  onPasswordUIRequest: { _ in },
-                                                 onMagicLinkUIRequest: { _, _ in },
+                                                 onMagicLinkRequest: { _ in },
+                                                 onMagicLinkSent: { _, _ in },
                                                  onError: { _ in })
 
         // When
@@ -28,7 +29,8 @@ final class WPComEmailLoginViewModelTests: XCTestCase {
                                                  requiresConnectionOnly: true,
                                                  allowAccountCreation: false,
                                                  onPasswordUIRequest: { _ in },
-                                                 onMagicLinkUIRequest: { _, _ in },
+                                                 onMagicLinkRequest: { _ in },
+                                                 onMagicLinkSent: { _, _ in },
                                                  onError: { _ in })
 
         // When
@@ -45,7 +47,8 @@ final class WPComEmailLoginViewModelTests: XCTestCase {
                                                  requiresConnectionOnly: false,
                                                  allowAccountCreation: false,
                                                  onPasswordUIRequest: { _ in },
-                                                 onMagicLinkUIRequest: { _, _ in },
+                                                 onMagicLinkRequest: { _ in },
+                                                 onMagicLinkSent: { _, _ in },
                                                  onError: { _ in })
 
         // When
@@ -62,7 +65,8 @@ final class WPComEmailLoginViewModelTests: XCTestCase {
                                                  requiresConnectionOnly: true,
                                                  allowAccountCreation: false,
                                                  onPasswordUIRequest: { _ in },
-                                                 onMagicLinkUIRequest: { _, _ in },
+                                                 onMagicLinkRequest: { _ in },
+                                                 onMagicLinkSent: { _, _ in },
                                                  onError: { _ in })
 
         // When
@@ -79,7 +83,8 @@ final class WPComEmailLoginViewModelTests: XCTestCase {
                                                  requiresConnectionOnly: true,
                                                  allowAccountCreation: false,
                                                  onPasswordUIRequest: { _ in },
-                                                 onMagicLinkUIRequest: { _, _ in },
+                                                 onMagicLinkRequest: { _ in },
+                                                 onMagicLinkSent: { _, _ in },
                                                  onError: { _ in })
 
         // When
@@ -101,14 +106,15 @@ final class WPComEmailLoginViewModelTests: XCTestCase {
                                                  allowAccountCreation: false,
                                                  accountService: mockAccountService,
                                                  onPasswordUIRequest: { _ in },
-                                                 onMagicLinkUIRequest: { _, _ in },
+                                                 onMagicLinkRequest: { _ in },
+                                                 onMagicLinkSent: { _, _ in },
                                                  onError: { _ in })
         // Confidence checks
         XCTAssertFalse(mockAccountService.triggeredIsPasswordlessAccount)
         XCTAssertFalse(mockAccountService.triggeredRequestAuthenticationLink)
 
         // When
-        await viewModel.checkWordPressComAccount(email: "mail@example.com")
+        await viewModel.checkWordPressComAccount(emailOrUsername: "mail@example.com")
 
         // Then
         XCTAssertTrue(mockAccountService.triggeredIsPasswordlessAccount)
@@ -125,10 +131,11 @@ final class WPComEmailLoginViewModelTests: XCTestCase {
                                                  allowAccountCreation: false,
                                                  accountService: mockAccountService,
                                                  onPasswordUIRequest: { _ in },
-                                                 onMagicLinkUIRequest: { _, _ in },
+                                                 onMagicLinkRequest: { _ in },
+                                                 onMagicLinkSent: { _, _ in },
                                                  onError: { _ in triggeredOnError = true })
         // When
-        await viewModel.checkWordPressComAccount(email: "mail@example.com")
+        await viewModel.checkWordPressComAccount(emailOrUsername: "mail@example.com")
 
         // Then
         XCTAssertTrue(triggeredOnError)
@@ -144,10 +151,11 @@ final class WPComEmailLoginViewModelTests: XCTestCase {
                                                  allowAccountCreation: false,
                                                  accountService: mockAccountService,
                                                  onPasswordUIRequest: { _ in triggeredPasswordUIRequest = true },
-                                                 onMagicLinkUIRequest: { _, _ in },
+                                                 onMagicLinkRequest: { _ in },
+                                                 onMagicLinkSent: { _, _ in },
                                                  onError: { _ in })
         // When
-        await viewModel.checkWordPressComAccount(email: "mail@example.com")
+        await viewModel.checkWordPressComAccount(emailOrUsername: "mail@example.com")
 
         // Then
         XCTAssertTrue(triggeredPasswordUIRequest)
@@ -162,7 +170,8 @@ final class WPComEmailLoginViewModelTests: XCTestCase {
                                                  allowAccountCreation: false,
                                                  accountService: mockAccountService,
                                                  onPasswordUIRequest: { _ in },
-                                                 onMagicLinkUIRequest: { _, _ in triggeredOnMagicLinkUIRequest = true },
+                                                 onMagicLinkRequest: { _ in },
+                                                 onMagicLinkSent: { _, _ in triggeredOnMagicLinkUIRequest = true},
                                                  onError: { _ in })
         // When
         await viewModel.requestAuthenticationLink(email: "mail@example.com")
@@ -181,7 +190,8 @@ final class WPComEmailLoginViewModelTests: XCTestCase {
                                                  allowAccountCreation: false,
                                                  accountService: mockAccountService,
                                                  onPasswordUIRequest: { _ in },
-                                                 onMagicLinkUIRequest: { _, _ in },
+                                                 onMagicLinkRequest: { _ in },
+                                                 onMagicLinkSent: { _, _ in },
                                                  onError: { _ in triggeredOnError = true })
         // When
         await viewModel.requestAuthenticationLink(email: "mail@example.com")
@@ -205,11 +215,12 @@ final class WPComEmailLoginViewModelTests: XCTestCase {
                                                  allowAccountCreation: true,
                                                  accountService: mockAccountService,
                                                  onPasswordUIRequest: { _ in },
-                                                 onMagicLinkUIRequest: { _, isSignup in triggeredOnMagicLinkUIRequest = isSignup },
+                                                 onMagicLinkRequest: { _ in },
+                                                 onMagicLinkSent: { _, isSignup in triggeredOnMagicLinkUIRequest = isSignup },
                                                  onError: { _ in })
 
         // When
-        await viewModel.checkWordPressComAccount(email: "mail@example.com")
+        await viewModel.checkWordPressComAccount(emailOrUsername: "mail@example.com")
 
         // Then
         XCTAssertTrue(triggeredOnMagicLinkUIRequest)
@@ -230,11 +241,12 @@ final class WPComEmailLoginViewModelTests: XCTestCase {
                                                  allowAccountCreation: false,
                                                  accountService: mockAccountService,
                                                  onPasswordUIRequest: { _ in },
-                                                 onMagicLinkUIRequest: { _, _ in },
+                                                 onMagicLinkRequest: { _ in },
+                                                 onMagicLinkSent: { _, _ in },
                                                  onError: { _ in triggeredOnError = true })
 
         // When
-        await viewModel.checkWordPressComAccount(email: "mail@example.com")
+        await viewModel.checkWordPressComAccount(emailOrUsername: "mail@example.com")
 
         // Then
         XCTAssertTrue(triggeredOnError)
@@ -255,13 +267,40 @@ final class WPComEmailLoginViewModelTests: XCTestCase {
                                                  allowAccountCreation: true,
                                                  accountService: mockAccountService,
                                                  onPasswordUIRequest: { _ in },
-                                                 onMagicLinkUIRequest: { _, _ in },
+                                                 onMagicLinkRequest: { _ in },
+                                                 onMagicLinkSent: { _, _ in },
                                                  onError: { errorMessage = $0 })
 
         // When
-        await viewModel.checkWordPressComAccount(email: "unknown_username")
+        await viewModel.checkWordPressComAccount(emailOrUsername: "unknown_username")
 
         // Then
         XCTAssertEqual(errorMessage, WPComEmailLoginViewModel.Localization.unknownUsername)
     }
+
+    func test_given_emailLoginNotAllowed_when_signingIn_then_trigger_onMagicLinkRequest() async {
+        // Given
+        let mockAccountService = MockWordPressComAccountService()
+        mockAccountService.passwordlessAccountCheckError = WordPressAPIError.endpointError(
+            WordPressComRestApiEndpointError(
+                code: WordPressComRestApiErrorCode.unknown,
+                apiErrorCode: "email_login_not_allowed"
+            )
+        )
+        var triggeredOnMagicLinkRequest = false
+        let viewModel = WPComEmailLoginViewModel(siteURL: "https://example.com",
+                                                 requiresConnectionOnly: true,
+                                                 allowAccountCreation: true,
+                                                 accountService: mockAccountService,
+                                                 onPasswordUIRequest: { _ in },
+                                                 onMagicLinkRequest: { _ in triggeredOnMagicLinkRequest = true },
+                                                 onMagicLinkSent: { _, _ in },
+                                                 onError: { _ in })
+
+        // When
+        await viewModel.checkWordPressComAccount(emailOrUsername: "email@example.com")
+
+        // Then
+        XCTAssertTrue(triggeredOnMagicLinkRequest)
+    }
 }
diff --git a/WooCommerce/WooCommerceTests/ViewRelated/JetpackSetup/WPComLogin/WPComMagicLinkRequestViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/JetpackSetup/WPComLogin/WPComMagicLinkRequestViewModelTests.swift
new file mode 100644
index 00000000000..55ae209fb77
--- /dev/null
+++ b/WooCommerce/WooCommerceTests/ViewRelated/JetpackSetup/WPComLogin/WPComMagicLinkRequestViewModelTests.swift
@@ -0,0 +1,60 @@
+import XCTest
+@testable import WordPressAuthenticator
+@testable import WooCommerce
+
+final class WPComMagicLinkRequestViewModelTests: XCTestCase {
+    func test_givenSuccessMagicLink_when_magicLinkRequested_then_onMagicLinkSent_called() async {
+        // Given
+        let mockAccountService = MockWordPressComAccountService()
+        mockAccountService.authenticationLinkRequestError = nil
+
+        var isMagicLinkSent = false
+        let viewModel = WPComMagicLinkRequestViewModel(email: "email@example.com",
+                                                       onMagicLinkSent: {_ in isMagicLinkSent = true },
+                                                       onUseUsernamePassword: { },
+                                                       onError: { _ in },
+                                                       accountService: mockAccountService
+        )
+
+        // When
+        await viewModel.sendMagicLink()
+
+        // Then
+        XCTAssertTrue(isMagicLinkSent)
+    }
+
+    func test_givenFailedMagicLink_when_magicLinkRequested_then_onError_called() async {
+        // Given
+        let mockAccountService = MockWordPressComAccountService()
+        mockAccountService.authenticationLinkRequestError = NSError(domain: "Test", code: 401)
+
+        var isErrorShown = false
+        let viewModel = WPComMagicLinkRequestViewModel(email: "email@example.com",
+                                                       onMagicLinkSent: {_ in },
+                                                       onUseUsernamePassword: { },
+                                                       onError: { _ in isErrorShown = true },
+                                                       accountService: mockAccountService
+        )
+
+        // When
+        await viewModel.sendMagicLink()
+
+        // Then
+        XCTAssertTrue(isErrorShown)
+    }
+
+    @MainActor func test_when_useUsernamePassword_called_then_handleCallback() {
+        var isUsernamePasswordUsed = false
+        let viewModel = WPComMagicLinkRequestViewModel(email: "email@example.com",
+                                                       onMagicLinkSent: {_ in },
+                                                       onUseUsernamePassword: { isUsernamePasswordUsed = true },
+                                                       onError: { _ in }
+        )
+
+        // When
+        viewModel.useUsernamePassword()
+
+        // Then
+        XCTAssertTrue(isUsernamePasswordUsed)
+    }
+}


Reviews:
user: login: jaclync
body: > TC3: Fallback to username/password

> Start Jetpack Setup from the dashboard or the app settings.
> Enter the email address shared here 37602-pb
> Tap on Use username and password instead.
> Sign in using WordPress.com credentials..

For this test case with the patch applied, I didn't see a way to enter the password after the username. Not sure if I missed something? Used a different email in step 2 for the screencast:

https://github.com/user-attachments/assets/95d4a5c0-32b0-4508-ba1f-cce3737d8453

Otherwise, the code looks good.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T13:16:58Z
user: login: hichamboushaba
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T13:29:34Z
user: login: hichamboushaba
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T13:40:12Z
user: login: hichamboushaba
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T13:41:15Z
user: login: hichamboushaba
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T13:41:48Z
user: login: hichamboushaba
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T13:42:37Z
user: login: hichamboushaba
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T13:43:29Z
user: login: hichamboushaba
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T13:43:54Z
user: login: jaclync
body: LGTM, thanks for the updates! :shipit: 

> I went for the fix with the least changes, but from my basic understanding, I think A better fix that ensures we avoid issues like this in the future would be to follow Apple's recommendation (from this [video](https://developer.apple.com/videos/play/wwdc2021/10019/)) by annotation the whole models (the ViewModels and the Coordinator in our case) with @MainActor, am I right? I'm just asking to learn here, as doing this will require many changes outside of the PR scope.

I haven't watched the video yet (or maybe I did but forgot), but that's my understanding as well to mark the whole class as `@MainActor` when the interactions are with the UI. Regarding why we don't usually do this in the codebase, I'm guessing it's because there are a bunch of other requirements when marking a class as `@MainActor` with more changes. I'll have to learn more about this, but next time I create a UI related class I'll consider marking the class as `@MainActor` first.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T09:40:08Z
user: login: hichamboushaba
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T10:34:58Z

Review comments:
diff_hunk: @@ -73,43 +77,48 @@ final class WPComEmailLoginViewModel: ObservableObject {
     }
 
     @MainActor
-    func checkWordPressComAccount(email: String) async {
+    func checkWordPressComAccount(emailOrUsername: String) async {
         do {
             let passwordless = try await withCheckedThrowingContinuation { continuation in
-                accountService.isPasswordlessAccount(username: email, success: { passwordless in
+                accountService.isPasswordlessAccount(username: emailOrUsername, success: { passwordless in
                     continuation.resume(returning: passwordless)
                 }, failure: { error in
                     DDLogError("⛔️ Error checking for passwordless account: \(error)")
                     continuation.resume(throwing: error)
                 })
             }
-            await startAuthentication(email: email, isPasswordlessAccount: passwordless)
+            await startAuthentication(emailOrUsername: emailOrUsername, isPasswordlessAccount: passwordless)
         } catch {
-            guard allowAccountCreation,
-                  let apiError = error as? WordPressAPIError<WordPressComRestApiEndpointError>,
-                  case .endpointError(let endpointError) = apiError,
-                  endpointError.apiErrorCode == Constants.unknownUserErrorCode else {
-                analytics.track(event: .JetpackSetup.loginFlow(step: .emailAddress, failure: error))
-                onError(error.localizedDescription)
+            let apiErrorCode: String? = {
+                if let apiError = error as? WordPressAPIError<WordPressComRestApiEndpointError>,
+                   case .endpointError(let endpointError) = apiError {
+                    return endpointError.apiErrorCode
+                }
+                return nil
+            }()
+
+            if allowAccountCreation,
+               apiErrorCode == Constants.unknownUserErrorCode {
+                await handleUnkownUserError(emailOrUsername: emailOrUsername, error: error)
                 return
             }
 
-            guard email.isValidEmail() else {
-                analytics.track(event: .JetpackSetup.loginFlow(step: .emailAddress, failure: error))
-                onError(Localization.unknownUsername)
+            if apiErrorCode == Constants.emailNotAllowed {
+                onMagicLinkRequest(emailOrUsername)
                 return
             }
 
-            await requestAuthenticationLink(email: email, forAccountCreation: true)
+            analytics.track(event: .JetpackSetup.loginFlow(step: .emailAddress, failure: error))
path: WooCommerce/Classes/Authentication/WPComLogin/WPComEmailLoginViewModel.swift
user: login: jaclync
body: non-blocking observation: it felt a bit confusing when this flow can be either username (application password login) or email while the class and event names are still referring to "email." It looks like the class was already taking in email/username before so it might not be related to this PR.

Just trying to understand better, not super familiar with the code here. A non-email wp-admin username cannot be used for `requestWPComAuthLink`. Is that why we're checking `isValidEmail` to only request the WPCOM auth link?
created_at: 2025-04-07T08:19:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,33 @@
+import SwiftUI
+import Kingfisher
+
+struct WPComLoginGravatarView: View {
path: WooCommerce/Classes/Authentication/WPComLogin/WPComLoginGravatarView.swift
user: login: jaclync
body: nit: a preview might be helpful for seeing how this view works quickly.
created_at: 2025-04-07T12:34:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,109 @@
+import SwiftUI
+import Kingfisher
+
+class WPComMagicLinkRequestHostingController: UIHostingController<WPComMagicLinkRequestView> {
path: WooCommerce/Classes/Authentication/WPComLogin/WPComMagicLinkRequestView.swift
user: login: jaclync
body: nit: can be final?
created_at: 2025-04-07T12:35:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,109 @@
+import SwiftUI
+import Kingfisher
+
+class WPComMagicLinkRequestHostingController: UIHostingController<WPComMagicLinkRequestView> {
+    @available(*, unavailable)
+    required dynamic init?(coder aDecoder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    init(title: String,
+         viewModel: WPComMagicLinkRequestViewModel) {
+        let view = WPComMagicLinkRequestView(title: title,
+                                             viewModel: viewModel)
+        super.init(rootView: view)
+    }
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        configureTransparentNavigationBar()
+    }
+}
+
+struct WPComMagicLinkRequestView: View {
+    @ObservedObject private var viewModel: WPComMagicLinkRequestViewModel
path: WooCommerce/Classes/Authentication/WPComLogin/WPComMagicLinkRequestView.swift
user: login: jaclync
body: Just wanted to make a note that this view model doesn't seem to be owned by the view, nor by the call site of the hosting controller. A `@StateObject` might be more suitable for this case to be owned by the top level view. Things might work normally, but bugs can surface in unexpected ways when a stateful object isn't owned.

I generally follow [this](https://www.hackingwithswift.com/quick-start/swiftui/whats-the-difference-between-observedobject-state-and-environmentobject):

> There is one important difference between @StateObject and @ObservedObject, which is ownership – which view created the object, and which view is just watching it.
> The rule is this: whichever view is the first to create your object must use @StateObject, to tell SwiftUI it is the owner of the data and is responsible for keeping it alive. All other views must use @ObservedObject, to tell SwiftUI they want to watch the object for changes but don’t own it directly.
created_at: 2025-04-07T12:41:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,109 @@
+import SwiftUI
+import Kingfisher
+
+class WPComMagicLinkRequestHostingController: UIHostingController<WPComMagicLinkRequestView> {
+    @available(*, unavailable)
+    required dynamic init?(coder aDecoder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    init(title: String,
+         viewModel: WPComMagicLinkRequestViewModel) {
+        let view = WPComMagicLinkRequestView(title: title,
+                                             viewModel: viewModel)
+        super.init(rootView: view)
+    }
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        configureTransparentNavigationBar()
+    }
+}
+
+struct WPComMagicLinkRequestView: View {
+    @ObservedObject private var viewModel: WPComMagicLinkRequestViewModel
+
+    /// Title to display at the top of the view.
+    private let title: String
+
+    init(title: String,
+         viewModel: WPComMagicLinkRequestViewModel) {
+        self.title = title
+        self.viewModel = viewModel
+    }
+
+    var body: some View {
+        ScrollView {
+            VStack(alignment: .leading, spacing: Constants.blockVerticalPadding) {
+                JetpackInstallHeaderView()
+
+                // Title
+                Text(title)
+                    .largeTitleStyle()
+
+                // Avatar and email
+                WPComLoginGravatarView(email: viewModel.email, gravatarURL: viewModel.avatarURL)
+
+                Text(Localization.label)
+                    .bodyStyle()
+
+                Spacer()
+            }
+            .padding(Constants.contentPadding)
+        }.safeAreaInset(edge: .bottom) {
+            VStack {
+                Button(Localization.sendLinkButton) {
+                    Task {
+                        await viewModel.sendMagicLink()
+                    }
+                }
+                .buttonStyle(PrimaryLoadingButtonStyle(isLoading: viewModel.isLoading))
+
+                Button(Localization.fallbackButton) {
+                    viewModel.useUsernamePassword()
+                }
+                .buttonStyle(SecondaryButtonStyle())
+            }
+            .padding(Constants.contentPadding)
+            .background(Color(uiColor: .systemBackground))
+        }
+    }
+}
+
+private extension WPComMagicLinkRequestView {
+    enum Constants {
+        static let blockVerticalPadding: CGFloat = 32
+        static let contentVerticalSpacing: CGFloat = 8
+        static let contentPadding: CGFloat = 16
+    }
+
+    enum Localization {
+        static let label = NSLocalizedString(
+            "wpcomMagicLinkRequestView.label",
+            value: "We'll email you a link that'll log you in instantly, no password needed.",
+            comment: "A message that informs the user that a magic link will be sent to their email address."
+        )
+        static let sendLinkButton = NSLocalizedString(
+            "wpcomMagicLinkRequestView.primaryAction",
+            value: "Send link by email",
+            comment: "Button title for the action of logging in with a password."
+        )
+        // For now this is hardcoded for the username/password fallback, if we need to support email/password in the future,
+        // we can add a  way to configure the fallback action.
+        static let fallbackButton = NSLocalizedString(
+            "wpcomMagicLinkRequestView.useUsernamePassword",
+            value: "Use username and password",
+            comment: "Button title for the action of signing in using WordPress.com username and password."
path: WooCommerce/Classes/Authentication/WPComLogin/WPComMagicLinkRequestView.swift
user: login: jaclync
body: nit check: are the username/password for WPCOM or wp-admin store credentials?
created_at: 2025-04-07T12:48:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,109 @@
+import SwiftUI
+import Kingfisher
+
+class WPComMagicLinkRequestHostingController: UIHostingController<WPComMagicLinkRequestView> {
+    @available(*, unavailable)
+    required dynamic init?(coder aDecoder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    init(title: String,
+         viewModel: WPComMagicLinkRequestViewModel) {
+        let view = WPComMagicLinkRequestView(title: title,
+                                             viewModel: viewModel)
+        super.init(rootView: view)
+    }
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        configureTransparentNavigationBar()
+    }
+}
+
+struct WPComMagicLinkRequestView: View {
+    @ObservedObject private var viewModel: WPComMagicLinkRequestViewModel
+
+    /// Title to display at the top of the view.
+    private let title: String
+
+    init(title: String,
+         viewModel: WPComMagicLinkRequestViewModel) {
+        self.title = title
+        self.viewModel = viewModel
+    }
+
+    var body: some View {
+        ScrollView {
+            VStack(alignment: .leading, spacing: Constants.blockVerticalPadding) {
+                JetpackInstallHeaderView()
+
+                // Title
+                Text(title)
+                    .largeTitleStyle()
+
+                // Avatar and email
+                WPComLoginGravatarView(email: viewModel.email, gravatarURL: viewModel.avatarURL)
+
+                Text(Localization.label)
+                    .bodyStyle()
+
+                Spacer()
+            }
+            .padding(Constants.contentPadding)
+        }.safeAreaInset(edge: .bottom) {
+            VStack {
+                Button(Localization.sendLinkButton) {
+                    Task {
+                        await viewModel.sendMagicLink()
+                    }
+                }
+                .buttonStyle(PrimaryLoadingButtonStyle(isLoading: viewModel.isLoading))
+
+                Button(Localization.fallbackButton) {
+                    viewModel.useUsernamePassword()
+                }
+                .buttonStyle(SecondaryButtonStyle())
+            }
+            .padding(Constants.contentPadding)
+            .background(Color(uiColor: .systemBackground))
+        }
+    }
+}
+
+private extension WPComMagicLinkRequestView {
+    enum Constants {
+        static let blockVerticalPadding: CGFloat = 32
+        static let contentVerticalSpacing: CGFloat = 8
+        static let contentPadding: CGFloat = 16
+    }
+
+    enum Localization {
+        static let label = NSLocalizedString(
+            "wpcomMagicLinkRequestView.label",
+            value: "We'll email you a link that'll log you in instantly, no password needed.",
+            comment: "A message that informs the user that a magic link will be sent to their email address."
+        )
+        static let sendLinkButton = NSLocalizedString(
+            "wpcomMagicLinkRequestView.primaryAction",
+            value: "Send link by email",
+            comment: "Button title for the action of logging in with a password."
path: WooCommerce/Classes/Authentication/WPComLogin/WPComMagicLinkRequestView.swift
user: login: jaclync
body: nit: maybe something like

```suggestion
            comment: "Button title for the action of sending a magic link email to log in to WordPress.com."
```
created_at: 2025-04-07T12:50:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,68 @@
+import Foundation
+import WooFoundation
+import WordPressAuthenticator
+import WordPressUI
+
+class WPComMagicLinkRequestViewModel: ObservableObject {
path: WooCommerce/Classes/Authentication/WPComLogin/WPComMagicLinkRequestViewModel.swift
user: login: jaclync
body: nit: can be final?
```suggestion
final class WPComMagicLinkRequestViewModel: ObservableObject {
```
created_at: 2025-04-07T12:51:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,68 @@
+import Foundation
+import WooFoundation
+import WordPressAuthenticator
+import WordPressUI
+
+class WPComMagicLinkRequestViewModel: ObservableObject {
+    let email: String
+
+    @Published private(set) var isLoading = false
+    let avatarURL: URL?
+
+    private let onMagicLinkSent: (String) -> Void
+    private let onUseUsernamePassword: () -> Void
+    private let onError: (String) -> Void
+
+    private let accountService: WordPressComAccountServiceProtocol
+    private let analytics: Analytics
+
+    init(email: String,
+         onMagicLinkSent: @escaping (String) -> Void,
+            onUseUsernamePassword: @escaping () -> Void,
path: WooCommerce/Classes/Authentication/WPComLogin/WPComMagicLinkRequestViewModel.swift
user: login: jaclync
body: super nit:
```suggestion
         onUseUsernamePassword: @escaping () -> Void,
```
created_at: 2025-04-07T12:52:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -73,43 +77,48 @@ final class WPComEmailLoginViewModel: ObservableObject {
     }
 
     @MainActor
-    func checkWordPressComAccount(email: String) async {
+    func checkWordPressComAccount(emailOrUsername: String) async {
         do {
             let passwordless = try await withCheckedThrowingContinuation { continuation in
-                accountService.isPasswordlessAccount(username: email, success: { passwordless in
+                accountService.isPasswordlessAccount(username: emailOrUsername, success: { passwordless in
                     continuation.resume(returning: passwordless)
                 }, failure: { error in
                     DDLogError("⛔️ Error checking for passwordless account: \(error)")
                     continuation.resume(throwing: error)
                 })
             }
-            await startAuthentication(email: email, isPasswordlessAccount: passwordless)
+            await startAuthentication(emailOrUsername: emailOrUsername, isPasswordlessAccount: passwordless)
         } catch {
-            guard allowAccountCreation,
-                  let apiError = error as? WordPressAPIError<WordPressComRestApiEndpointError>,
-                  case .endpointError(let endpointError) = apiError,
-                  endpointError.apiErrorCode == Constants.unknownUserErrorCode else {
-                analytics.track(event: .JetpackSetup.loginFlow(step: .emailAddress, failure: error))
-                onError(error.localizedDescription)
+            let apiErrorCode: String? = {
+                if let apiError = error as? WordPressAPIError<WordPressComRestApiEndpointError>,
+                   case .endpointError(let endpointError) = apiError {
+                    return endpointError.apiErrorCode
+                }
+                return nil
+            }()
+
+            if allowAccountCreation,
+               apiErrorCode == Constants.unknownUserErrorCode {
+                await handleUnkownUserError(emailOrUsername: emailOrUsername, error: error)
                 return
             }
 
-            guard email.isValidEmail() else {
-                analytics.track(event: .JetpackSetup.loginFlow(step: .emailAddress, failure: error))
-                onError(Localization.unknownUsername)
+            if apiErrorCode == Constants.emailNotAllowed {
+                onMagicLinkRequest(emailOrUsername)
                 return
             }
 
-            await requestAuthenticationLink(email: email, forAccountCreation: true)
+            analytics.track(event: .JetpackSetup.loginFlow(step: .emailAddress, failure: error))
path: WooCommerce/Classes/Authentication/WPComLogin/WPComEmailLoginViewModel.swift
user: login: hichamboushaba
body: > A non-email wp-admin username cannot be used for requestWPComAuthLink. Is that why we're checking isValidEmail to only request the WPCOM auth link?

One first clarification, everything we talk about in this PR is a WordPress.com credential, whether it's the username or the email.

And you are right, we can't create an account using the username alone, we need an email, so if the user enters an unknown username we don't trigger the account creation, this logic is not new, I just refactored the logic a bit given the additional error handling of this PR.
created_at: 2025-04-07T13:29:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,109 @@
+import SwiftUI
+import Kingfisher
+
+class WPComMagicLinkRequestHostingController: UIHostingController<WPComMagicLinkRequestView> {
+    @available(*, unavailable)
+    required dynamic init?(coder aDecoder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    init(title: String,
+         viewModel: WPComMagicLinkRequestViewModel) {
+        let view = WPComMagicLinkRequestView(title: title,
+                                             viewModel: viewModel)
+        super.init(rootView: view)
+    }
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        configureTransparentNavigationBar()
+    }
+}
+
+struct WPComMagicLinkRequestView: View {
+    @ObservedObject private var viewModel: WPComMagicLinkRequestViewModel
+
+    /// Title to display at the top of the view.
+    private let title: String
+
+    init(title: String,
+         viewModel: WPComMagicLinkRequestViewModel) {
+        self.title = title
+        self.viewModel = viewModel
+    }
+
+    var body: some View {
+        ScrollView {
+            VStack(alignment: .leading, spacing: Constants.blockVerticalPadding) {
+                JetpackInstallHeaderView()
+
+                // Title
+                Text(title)
+                    .largeTitleStyle()
+
+                // Avatar and email
+                WPComLoginGravatarView(email: viewModel.email, gravatarURL: viewModel.avatarURL)
+
+                Text(Localization.label)
+                    .bodyStyle()
+
+                Spacer()
+            }
+            .padding(Constants.contentPadding)
+        }.safeAreaInset(edge: .bottom) {
+            VStack {
+                Button(Localization.sendLinkButton) {
+                    Task {
+                        await viewModel.sendMagicLink()
+                    }
+                }
+                .buttonStyle(PrimaryLoadingButtonStyle(isLoading: viewModel.isLoading))
+
+                Button(Localization.fallbackButton) {
+                    viewModel.useUsernamePassword()
+                }
+                .buttonStyle(SecondaryButtonStyle())
+            }
+            .padding(Constants.contentPadding)
+            .background(Color(uiColor: .systemBackground))
+        }
+    }
+}
+
+private extension WPComMagicLinkRequestView {
+    enum Constants {
+        static let blockVerticalPadding: CGFloat = 32
+        static let contentVerticalSpacing: CGFloat = 8
+        static let contentPadding: CGFloat = 16
+    }
+
+    enum Localization {
+        static let label = NSLocalizedString(
+            "wpcomMagicLinkRequestView.label",
+            value: "We'll email you a link that'll log you in instantly, no password needed.",
+            comment: "A message that informs the user that a magic link will be sent to their email address."
+        )
+        static let sendLinkButton = NSLocalizedString(
+            "wpcomMagicLinkRequestView.primaryAction",
+            value: "Send link by email",
+            comment: "Button title for the action of logging in with a password."
+        )
+        // For now this is hardcoded for the username/password fallback, if we need to support email/password in the future,
+        // we can add a  way to configure the fallback action.
+        static let fallbackButton = NSLocalizedString(
+            "wpcomMagicLinkRequestView.useUsernamePassword",
+            value: "Use username and password",
+            comment: "Button title for the action of signing in using WordPress.com username and password."
path: WooCommerce/Classes/Authentication/WPComLogin/WPComMagicLinkRequestView.swift
user: login: hichamboushaba
body: As stated above, all credentials used in this flow are WPCom credentials, so the comment is correct.
created_at: 2025-04-07T13:40:12Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,33 @@
+import SwiftUI
+import Kingfisher
+
+struct WPComLoginGravatarView: View {
path: WooCommerce/Classes/Authentication/WPComLogin/WPComLoginGravatarView.swift
user: login: hichamboushaba
body: Good point, done in 45376540133ef8abcc7df57d7983a4e42b631f6f
created_at: 2025-04-07T13:41:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,109 @@
+import SwiftUI
+import Kingfisher
+
+class WPComMagicLinkRequestHostingController: UIHostingController<WPComMagicLinkRequestView> {
path: WooCommerce/Classes/Authentication/WPComLogin/WPComMagicLinkRequestView.swift
user: login: hichamboushaba
body: Good point, I'm more used to Kotlin where classes are final by default 😅, change applied in 85cc1717bfcb68f2a446f442af4496e0d8b1f910
created_at: 2025-04-07T13:41:47Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,109 @@
+import SwiftUI
+import Kingfisher
+
+class WPComMagicLinkRequestHostingController: UIHostingController<WPComMagicLinkRequestView> {
+    @available(*, unavailable)
+    required dynamic init?(coder aDecoder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    init(title: String,
+         viewModel: WPComMagicLinkRequestViewModel) {
+        let view = WPComMagicLinkRequestView(title: title,
+                                             viewModel: viewModel)
+        super.init(rootView: view)
+    }
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        configureTransparentNavigationBar()
+    }
+}
+
+struct WPComMagicLinkRequestView: View {
+    @ObservedObject private var viewModel: WPComMagicLinkRequestViewModel
path: WooCommerce/Classes/Authentication/WPComLogin/WPComMagicLinkRequestView.swift
user: login: hichamboushaba
body: Good point, while I know the difference, my lack of experience in SwiftUI makes me miss this difference sometimes, thanks for reminding me.

Please let me know if 75f3db1dfb66b85027de3d65c3bafdc6044c7337 matches what you are suggesting.
created_at: 2025-04-07T13:42:37Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,109 @@
+import SwiftUI
+import Kingfisher
+
+class WPComMagicLinkRequestHostingController: UIHostingController<WPComMagicLinkRequestView> {
+    @available(*, unavailable)
+    required dynamic init?(coder aDecoder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    init(title: String,
+         viewModel: WPComMagicLinkRequestViewModel) {
+        let view = WPComMagicLinkRequestView(title: title,
+                                             viewModel: viewModel)
+        super.init(rootView: view)
+    }
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        configureTransparentNavigationBar()
+    }
+}
+
+struct WPComMagicLinkRequestView: View {
+    @ObservedObject private var viewModel: WPComMagicLinkRequestViewModel
+
+    /// Title to display at the top of the view.
+    private let title: String
+
+    init(title: String,
+         viewModel: WPComMagicLinkRequestViewModel) {
+        self.title = title
+        self.viewModel = viewModel
+    }
+
+    var body: some View {
+        ScrollView {
+            VStack(alignment: .leading, spacing: Constants.blockVerticalPadding) {
+                JetpackInstallHeaderView()
+
+                // Title
+                Text(title)
+                    .largeTitleStyle()
+
+                // Avatar and email
+                WPComLoginGravatarView(email: viewModel.email, gravatarURL: viewModel.avatarURL)
+
+                Text(Localization.label)
+                    .bodyStyle()
+
+                Spacer()
+            }
+            .padding(Constants.contentPadding)
+        }.safeAreaInset(edge: .bottom) {
+            VStack {
+                Button(Localization.sendLinkButton) {
+                    Task {
+                        await viewModel.sendMagicLink()
+                    }
+                }
+                .buttonStyle(PrimaryLoadingButtonStyle(isLoading: viewModel.isLoading))
+
+                Button(Localization.fallbackButton) {
+                    viewModel.useUsernamePassword()
+                }
+                .buttonStyle(SecondaryButtonStyle())
+            }
+            .padding(Constants.contentPadding)
+            .background(Color(uiColor: .systemBackground))
+        }
+    }
+}
+
+private extension WPComMagicLinkRequestView {
+    enum Constants {
+        static let blockVerticalPadding: CGFloat = 32
+        static let contentVerticalSpacing: CGFloat = 8
+        static let contentPadding: CGFloat = 16
+    }
+
+    enum Localization {
+        static let label = NSLocalizedString(
+            "wpcomMagicLinkRequestView.label",
+            value: "We'll email you a link that'll log you in instantly, no password needed.",
+            comment: "A message that informs the user that a magic link will be sent to their email address."
+        )
+        static let sendLinkButton = NSLocalizedString(
+            "wpcomMagicLinkRequestView.primaryAction",
+            value: "Send link by email",
+            comment: "Button title for the action of logging in with a password."
path: WooCommerce/Classes/Authentication/WPComLogin/WPComMagicLinkRequestView.swift
user: login: hichamboushaba
body: Thanks for catching this 🤦 
created_at: 2025-04-07T13:43:29Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,68 @@
+import Foundation
+import WooFoundation
+import WordPressAuthenticator
+import WordPressUI
+
+class WPComMagicLinkRequestViewModel: ObservableObject {
path: WooCommerce/Classes/Authentication/WPComLogin/WPComMagicLinkRequestViewModel.swift
user: login: hichamboushaba
body: Fixed also in the commit 85cc171
created_at: 2025-04-07T13:43:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,43 @@
+import SwiftUI
+import Kingfisher
+
+struct WPComLoginGravatarView: View {
+    let email: String
+    let gravatarURL: URL?
+
+    var body: some View {
+        HStack(spacing: Constants.spacing) {
+            KFImage(gravatarURL)
+                .resizable()
+                .clipShape(Circle())
+                .frame(width: Constants.avatarSize, height: Constants.avatarSize)
+
+            Text(email)
+
+            Spacer()
+        }
+        .padding(Constants.avatarPadding)
+        .background(
+            RoundedRectangle(cornerRadius: 8, style: .continuous)
+                .strokeBorder(.gray, lineWidth: 1)
+        )
+    }
+}
+
+private extension WPComLoginGravatarView {
+    enum Constants {
+        static let spacing: CGFloat = 16
+        static let avatarSize: CGFloat = 32
+        static let avatarPadding: EdgeInsets = .init(top: 8, leading: 16, bottom: 8, trailing: 16)
+    }
+}
+
+struct WPComLoginGravatarView_Previews: PreviewProvider {
+    static var previews: some View {
+        WPComLoginGravatarView(
+            email: "example@example.com",
+            gravatarURL: URL(string: "https://www.gravatar.com/avatar/example")
+        )
+        .previewLayout(.sizeThatFits)
+    }
+}
path: WooCommerce/Classes/Authentication/WPComLogin/WPComLoginGravatarView.swift
user: login: jaclync
body: nit: nice preview, we can also use the latest preview syntax `#Preview { ... }` as in the recent previews in the codebase.
created_at: 2025-04-08T09:28:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,109 @@
+import SwiftUI
+import Kingfisher
+
+class WPComMagicLinkRequestHostingController: UIHostingController<WPComMagicLinkRequestView> {
+    @available(*, unavailable)
+    required dynamic init?(coder aDecoder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    init(title: String,
+         viewModel: WPComMagicLinkRequestViewModel) {
+        let view = WPComMagicLinkRequestView(title: title,
+                                             viewModel: viewModel)
+        super.init(rootView: view)
+    }
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        configureTransparentNavigationBar()
+    }
+}
+
+struct WPComMagicLinkRequestView: View {
+    @ObservedObject private var viewModel: WPComMagicLinkRequestViewModel
path: WooCommerce/Classes/Authentication/WPComLogin/WPComMagicLinkRequestView.swift
user: login: jaclync
body: Looks good 👍 
created_at: 2025-04-08T09:32:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,43 @@
+import SwiftUI
+import Kingfisher
+
+struct WPComLoginGravatarView: View {
+    let email: String
+    let gravatarURL: URL?
+
+    var body: some View {
+        HStack(spacing: Constants.spacing) {
+            KFImage(gravatarURL)
+                .resizable()
+                .clipShape(Circle())
+                .frame(width: Constants.avatarSize, height: Constants.avatarSize)
+
+            Text(email)
+
+            Spacer()
+        }
+        .padding(Constants.avatarPadding)
+        .background(
+            RoundedRectangle(cornerRadius: 8, style: .continuous)
+                .strokeBorder(.gray, lineWidth: 1)
+        )
+    }
+}
+
+private extension WPComLoginGravatarView {
+    enum Constants {
+        static let spacing: CGFloat = 16
+        static let avatarSize: CGFloat = 32
+        static let avatarPadding: EdgeInsets = .init(top: 8, leading: 16, bottom: 8, trailing: 16)
+    }
+}
+
+struct WPComLoginGravatarView_Previews: PreviewProvider {
+    static var previews: some View {
+        WPComLoginGravatarView(
+            email: "example@example.com",
+            gravatarURL: URL(string: "https://www.gravatar.com/avatar/example")
+        )
+        .previewLayout(.sizeThatFits)
+    }
+}
path: WooCommerce/Classes/Authentication/WPComLogin/WPComLoginGravatarView.swift
user: login: hichamboushaba
body: TIL, updated in last commit.
created_at: 2025-04-08T10:34:57Z
author_association: MEMBER

PR comments:
user: login: dangermattic
created_at: 2025-04-05T17:28:20Z
body: <!--
  0 Errors
  1 Warning: This PR is larger than 300 lin...
  0 Messages
  0 Markdowns
-->
<table>
  <thead>
    <tr>
      <th width="50"></th>
      <th width="100%" data-danger-table="true" data-kind="Warning">
          1 Warning
      </th>
     </tr>
  </thead>
  <tbody>
    <tr>
      <td>:warning:</td>
      <td data-sticky="false">This PR is larger than 300 lines of changes. Please consider splitting it into smaller PRs for easier and faster reviews.</td>
    </tr>
  </tbody>
</table>

<p align="right" data-meta="generated_by_pr-check">
  Generated by :no_entry_sign: <a href="https://danger.systems/">Danger</a>
</p>

author_association: COLLABORATOR
user: login: wpmobilebot
created_at: 2025-04-05T17:37:54Z
body: <!-- REUSE_ID: prototype-build-link -->

<p><img align='top' src='https://raw.githubusercontent.com/buildkite/emojis/main/img-buildkite-64/firebase.png' width='20px' alt='App Icon' />📲 You can test the changes from this Pull Request in <b>WooCommerce iOS Prototype</b> by scanning the QR code below to install the corresponding build.</p>
<table>
<tr>
  <td rowspan='6' width='260px'><img src='https://api.qrserver.com/v1/create-qr-code/?size=500x500&qzone=4&data=https%3A%2F%2Fappdistribution.firebase.google.com%2Ftesterapps%2F1%3A124902176124%3Aios%3A02259de1e7c42b291620f9%2Freleases%2F4l5i96m54aqko%3Futm_source%3Dfastlane' width='250' height='250' /></td>
  <td><b>App Name</b></td><td>WooCommerce iOS Prototype</td>
</tr>
<tr><td><b>Build Number</b></td><td><code>29231</code></td></tr>
<tr><td><b>Version</b></td><td><code>PR #15491</code></td></tr>
<tr><td><b>Bundle ID</b></td><td><code>com.automattic.alpha.woocommerce</code></td></tr>
<tr><td><b>Commit</b></td><td>d34ebc5c4cc2eac3917359e527b1215529454535</td></tr>
<tr><td><b>Installation URL</b></td><td><a href='https://appdistribution.firebase.google.com/testerapps/1:124902176124:ios:02259de1e7c42b291620f9/releases/4l5i96m54aqko?utm_source=fastlane'>4l5i96m54aqko</a></td></tr>
</table>
<em>Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.</em>

author_association: COLLABORATOR
user: login: hichamboushaba
created_at: 2025-04-05T19:45:50Z
body: Hey @jaclync, as Huong is AFK, I requested your review on this PR, given your familiarity with Jetpack Setup in the past. If you're unable to take a look, no worries at all- just let me know! 😊
author_association: MEMBER
user: login: jaclync
created_at: 2025-04-07T08:37:04Z
body: Hey Hicham, no problem I started reviewing it. It's been a while and it's taking me some time to understand the changes, I will finish the review today/tomorrow (AFK intermittently).
author_association: CONTRIBUTOR
user: login: hichamboushaba
created_at: 2025-04-07T08:39:44Z
body: Thank you @jaclync 🙏 , this is not urgent, so take your time.
author_association: MEMBER
user: login: hichamboushaba
created_at: 2025-04-07T13:27:48Z
body: Thanks @jaclync for the review.

> For this test case with the patch applied, I didn't see a way to enter the password after the username. Not sure if I missed something? Used a different email in step 2 for the screencast:

Sorry for the sloppy mistake in the test steps, the patch was missing a condition check, and it meant it'll use the magic link regardless of the flow, please re-test using this [patch](https://github.com/user-attachments/files/19632878/patch.patch).

Also, the mail I mentioned doesn't need to be used for this test case, it's just a copy paste from the previous step.

--- 
I'll look into the other comments and try to address them in the meantime.
author_association: MEMBER
user: login: hichamboushaba
created_at: 2025-04-07T13:44:49Z
body: Thanks again @jaclync for the great review, I've tried addressing the comments, ready for another round.
author_association: MEMBER
user: login: jaclync
created_at: 2025-04-08T06:32:16Z
body: > TC3: Fallback to username/password

Thanks for the updated patch! I tried it and can enter password now. Entering correct WPCOM username/password worked for me. However, if I entered an invalid WPCOM username (jaclyn.y.chen in my case) I ended up with a crash consistently when trying to present an alert from a non-main thread:

<img width="1624" alt="Screenshot 2025-04-08 at 3 13 30 PM" src="https://github.com/user-attachments/assets/a2554795-0ccb-4de0-a139-ba3fda523805" />

https://github.com/woocommerce/woocommerce-ios/blob/issue/15443-login-suspicious-email-jetpack/WooCommerce/Classes/ViewRelated/JetpackSetup/JetpackSetupCoordinator.swift#L495

Marking the relevant method @MainActor probably fixes it.
author_association: CONTRIBUTOR
user: login: hichamboushaba
created_at: 2025-04-08T08:10:26Z
body: Great catch @jaclync, I pushed a commit that fixes the crash.

I went for the fix with the least changes, but from my basic understanding, I think A better fix that ensures we avoid issues like this in the future would be to follow Apple's recommendation (from this [video](https://developer.apple.com/videos/play/wwdc2021/10019/)) by annotation the whole models (the ViewModels and the Coordinator in our case) with `@MainActor`, am I right? I'm just asking to learn here, as doing this will require many changes outside of the PR scope.
author_association: MEMBER
user: login: hichamboushaba
created_at: 2025-04-08T10:35:16Z
body: Thanks again @jaclync for the review.
author_association: MEMBER