Skip to content

Commit 6c9af37

Browse files
authored
Merge pull request #8142 from woocommerce/issue/8075-site-credential-screen
Login: Add new screen for site credential login in the Jetpack setup flow
2 parents a6cba95 + 3880746 commit 6c9af37

File tree

8 files changed

+537
-29
lines changed

8 files changed

+537
-29
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import UIKit
2+
import Yosemite
3+
4+
/// Coordinates navigation for the Jetpack setup flow during login.
5+
final class LoginJetpackSetupCoordinator: Coordinator {
6+
let navigationController: UINavigationController
7+
8+
private let siteURL: String
9+
/// Whether Jetpack is installed and activated and only connection needs to be handled.
10+
private let connectionOnly: Bool
11+
private let stores: StoresManager
12+
private let analytics: Analytics
13+
14+
init(siteURL: String,
15+
connectionOnly: Bool,
16+
navigationController: UINavigationController,
17+
stores: StoresManager = ServiceLocator.stores,
18+
analytics: Analytics = ServiceLocator.analytics) {
19+
self.siteURL = siteURL
20+
self.connectionOnly = connectionOnly
21+
self.navigationController = navigationController
22+
self.stores = stores
23+
self.analytics = analytics
24+
}
25+
26+
func start() {
27+
let siteCredentialUI = SiteCredentialLoginHostingViewController(siteURL: siteURL, connectionOnly: connectionOnly)
28+
navigationController.present(UINavigationController(rootViewController: siteCredentialUI), animated: true)
29+
}
30+
}
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import SwiftUI
2+
3+
/// Hosting controller that wraps the `SiteCredentialLoginView`.
4+
final class SiteCredentialLoginHostingViewController: UIHostingController<SiteCredentialLoginView> {
5+
init(siteURL: String, connectionOnly: Bool) {
6+
let viewModel = SiteCredentialLoginViewModel(siteURL: siteURL)
7+
super.init(rootView: SiteCredentialLoginView(connectionOnly: connectionOnly, viewModel: viewModel))
8+
}
9+
10+
@available(*, unavailable)
11+
required dynamic init?(coder aDecoder: NSCoder) {
12+
fatalError("init(coder:) has not been implemented")
13+
}
14+
15+
override func viewDidLoad() {
16+
super.viewDidLoad()
17+
18+
configureNavigationBarAppearance()
19+
}
20+
21+
/// Shows a transparent navigation bar without a bottom border.
22+
private func configureNavigationBarAppearance() {
23+
let appearance = UINavigationBarAppearance()
24+
appearance.configureWithTransparentBackground()
25+
appearance.backgroundColor = .systemBackground
26+
27+
navigationItem.standardAppearance = appearance
28+
navigationItem.scrollEdgeAppearance = appearance
29+
navigationItem.compactAppearance = appearance
30+
31+
let title = NSLocalizedString("Cancel", comment: "Button to dismiss the site credential login screen")
32+
navigationItem.leftBarButtonItem = UIBarButtonItem(title: title, style: .plain, target: self, action: #selector(dismissView))
33+
}
34+
35+
@objc
36+
private func dismissView() {
37+
dismiss(animated: true)
38+
}
39+
}
40+
41+
/// The view for inputing site credentials.
42+
///
43+
struct SiteCredentialLoginView: View {
44+
45+
/// Whether Jetpack is installed and activated and only connection needs to be handled.
46+
private let connectionOnly: Bool
47+
48+
@ObservedObject private var viewModel: SiteCredentialLoginViewModel
49+
50+
@FocusState private var keyboardIsShown: Bool
51+
52+
@State private var showsSecureInput: Bool = true
53+
54+
// Tracks the scale of the view due to accessibility changes.
55+
@ScaledMetric private var scale: CGFloat = 1.0
56+
57+
init(connectionOnly: Bool, viewModel: SiteCredentialLoginViewModel) {
58+
self.connectionOnly = connectionOnly
59+
self.viewModel = viewModel
60+
}
61+
62+
/// Attributed string for the description text
63+
private var descriptionAttributedString: NSAttributedString {
64+
let font: UIFont = .body
65+
let boldFont: UIFont = font.bold
66+
let siteName = viewModel.siteURL.trimHTTPScheme()
67+
let description = connectionOnly ? Localization.connectDescription : Localization.installDescription
68+
69+
let attributedString = NSMutableAttributedString(
70+
string: String(format: description, siteName),
71+
attributes: [.font: font,
72+
.foregroundColor: UIColor.text.withAlphaComponent(0.8)
73+
]
74+
)
75+
let boldSiteAddress = NSAttributedString(string: siteName, attributes: [.font: boldFont, .foregroundColor: UIColor.text])
76+
attributedString.replaceFirstOccurrence(of: siteName, with: boldSiteAddress)
77+
return attributedString
78+
}
79+
80+
var body: some View {
81+
ScrollView {
82+
VStack(alignment: .leading, spacing: Constants.blockVerticalPadding) {
83+
JetpackInstallHeaderView()
84+
85+
// title and description
86+
VStack(alignment: .leading, spacing: Constants.contentVerticalSpacing) {
87+
Text(Localization.title)
88+
.largeTitleStyle()
89+
AttributedText(descriptionAttributedString)
90+
}
91+
92+
// text fields
93+
VStack(alignment: .leading, spacing: Constants.fieldVerticalSpacing) {
94+
VStack(alignment: .leading, spacing: Constants.fieldVerticalSpacing) {
95+
TextField(Localization.enterUsername, text: $viewModel.username)
96+
.textFieldStyle(.plain)
97+
.focused($keyboardIsShown)
98+
.frame(height: Constants.fieldHeight * scale)
99+
Divider()
100+
}
101+
102+
VStack(alignment: .leading, spacing: Constants.fieldVerticalSpacing) {
103+
Group {
104+
if showsSecureInput {
105+
SecureField(Localization.enterPassword, text: $viewModel.password)
106+
.focused($keyboardIsShown)
107+
} else {
108+
TextField(Localization.enterPassword, text: $viewModel.password)
109+
.textFieldStyle(.plain)
110+
.focused($keyboardIsShown)
111+
}
112+
}
113+
.frame(height: Constants.fieldHeight * scale)
114+
.padding(.trailing, Constants.eyeButtonDimension * scale + Constants.eyeButtonHorizontalPadding)
115+
Divider()
116+
}
117+
.overlay(HStack {
118+
Spacer()
119+
// Button to show/hide the text field content.
120+
Button(action: {
121+
showsSecureInput.toggle()
122+
}) {
123+
Image(systemName: showsSecureInput ? "eye.slash" : "eye")
124+
.accentColor(Color(.textSubtle))
125+
.frame(width: Constants.eyeButtonDimension * scale,
126+
height: Constants.eyeButtonDimension * scale)
127+
}
128+
.offset(x: 0, y: -Constants.fieldVerticalSpacing/2)
129+
})
130+
131+
VStack(alignment: .leading, spacing: Constants.fieldVerticalSpacing) {
132+
Button {
133+
viewModel.resetPassword()
134+
} label: {
135+
Text(Localization.resetPassword)
136+
}
137+
.buttonStyle(PlainButtonStyle())
138+
.foregroundColor(Color(uiColor: .accent))
139+
Divider()
140+
}
141+
}
142+
143+
Label {
144+
Text(Localization.note)
145+
} icon: {
146+
Image(systemName: "info.circle")
147+
}
148+
.foregroundColor(Color(uiColor: .secondaryLabel))
149+
150+
Spacer()
151+
}
152+
}
153+
.safeAreaInset(edge: .bottom, content: {
154+
Button {
155+
// TODO-8075: add tracks
156+
keyboardIsShown = false
157+
viewModel.handleLogin()
158+
} label: {
159+
Text(connectionOnly ? Localization.connectJetpack : Localization.installJetpack)
160+
}
161+
.buttonStyle(PrimaryLoadingButtonStyle(isLoading: viewModel.isLoggingIn))
162+
.disabled(viewModel.primaryButtonDisabled)
163+
.padding(.top, Constants.contentVerticalSpacing)
164+
.background(Color(UIColor.systemBackground))
165+
})
166+
.padding()
167+
.alert(viewModel.errorMessage, isPresented: $viewModel.shouldShowErrorAlert) {
168+
Button(Localization.ok) {
169+
viewModel.shouldShowErrorAlert.toggle()
170+
}
171+
}
172+
}
173+
}
174+
175+
private extension SiteCredentialLoginView {
176+
enum Localization {
177+
static let title = NSLocalizedString("Log in to your store", comment: "Title of the site credential login screen in the Jetpack setup flow")
178+
static let installDescription = NSLocalizedString(
179+
"Log in to %1$@ with your store credentials to install Jetpack.",
180+
comment: "Message on the site credential login screen for installing Jetpack. The %1$@ is the site address."
181+
)
182+
static let connectDescription = NSLocalizedString(
183+
"Log in to %1$@ with your store credentials to connect Jetpack.",
184+
comment: "Message on the site credential login screen for connecting Jetpack. The %1$@ is the site address."
185+
)
186+
static let installJetpack = NSLocalizedString("Install Jetpack", comment: "Button title on the site credential login screen")
187+
static let connectJetpack = NSLocalizedString("Connect Jetpack", comment: "Button title on the site credential login screen")
188+
static let enterUsername = NSLocalizedString("Enter username", comment: "Placeholder for the username field on the site credential login screen")
189+
static let enterPassword = NSLocalizedString("Enter password", comment: "Placeholder for the password field on the site credential login screen")
190+
static let resetPassword = NSLocalizedString("Reset your password", comment: "Button to reset password on the site credential login screen")
191+
static let note = NSLocalizedString(
192+
"We will ask for your approval to complete the Jetpack connection.",
193+
comment: "Note at the bottom of the site credential login screen"
194+
)
195+
static let ok = NSLocalizedString("OK", comment: "Button to dismiss the error alert on the site credential login screen")
196+
}
197+
198+
enum Constants {
199+
static let blockVerticalPadding: CGFloat = 32
200+
static let contentVerticalSpacing: CGFloat = 8
201+
static let borderHeight: CGFloat = 1
202+
static let fieldVerticalSpacing: CGFloat = 16
203+
static let eyeButtonHorizontalPadding: CGFloat = 8
204+
static let eyeButtonDimension: CGFloat = 24
205+
static let fieldHeight: CGFloat = 24
206+
}
207+
}
208+
209+
struct SiteCredentialLoginView_Previews: PreviewProvider {
210+
static var previews: some View {
211+
let viewModel = SiteCredentialLoginViewModel(siteURL: "https://test.com")
212+
SiteCredentialLoginView(connectionOnly: true, viewModel: viewModel)
213+
SiteCredentialLoginView(connectionOnly: false, viewModel: viewModel)
214+
}
215+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import Foundation
2+
import WordPressAuthenticator
3+
import class Networking.UserAgent
4+
5+
/// View model for `SiteCredentialLoginView`.
6+
///
7+
final class SiteCredentialLoginViewModel: NSObject, ObservableObject {
8+
let siteURL: String
9+
10+
@Published var username: String = ""
11+
@Published var password: String = ""
12+
@Published private(set) var primaryButtonDisabled = true
13+
@Published private(set) var isLoggingIn = false
14+
@Published private(set) var errorMessage = ""
15+
@Published var shouldShowErrorAlert = false
16+
17+
private lazy var loginFacade = LoginFacade(dotcomClientID: ApiCredentials.dotcomAppId,
18+
dotcomSecret: ApiCredentials.dotcomSecret,
19+
userAgent: UserAgent.defaultUserAgent)
20+
21+
private var loginFields: LoginFields {
22+
let loginFields = LoginFields()
23+
loginFields.username = username
24+
loginFields.password = password
25+
loginFields.siteAddress = siteURL
26+
loginFields.meta.userIsDotCom = false
27+
return loginFields
28+
}
29+
30+
init(siteURL: String) {
31+
self.siteURL = siteURL
32+
super.init()
33+
loginFacade.delegate = self
34+
configurePrimaryButton()
35+
}
36+
37+
func handleLogin() {
38+
loginFacade.signIn(with: loginFields)
39+
isLoggingIn = true
40+
}
41+
42+
func resetPassword() {
43+
WordPressAuthenticator.openForgotPasswordURL(loginFields)
44+
}
45+
}
46+
47+
// MARK: Private helpers
48+
private extension SiteCredentialLoginViewModel {
49+
func configurePrimaryButton() {
50+
$username.combineLatest($password)
51+
.map { $0.isEmpty || $1.isEmpty }
52+
.assign(to: &$primaryButtonDisabled)
53+
}
54+
}
55+
56+
// MARK: LoginFacadeDelegate conformance
57+
//
58+
extension SiteCredentialLoginViewModel: LoginFacadeDelegate {
59+
func displayRemoteError(_ error: Error) {
60+
isLoggingIn = false
61+
62+
let err = error as NSError
63+
let wrongCredentials = err.domain == Constants.xmlrpcErrorDomain && err.code == Constants.invalidCredentialErrorCode
64+
errorMessage = wrongCredentials ? Localization.wrongCredentials : Localization.genericFailure
65+
shouldShowErrorAlert = true
66+
}
67+
68+
func finishedLogin(withUsername username: String, password: String, xmlrpc: String, options: [AnyHashable: Any] = [:]) {
69+
// TODO
70+
isLoggingIn = false
71+
print("🎉")
72+
}
73+
}
74+
75+
extension SiteCredentialLoginViewModel {
76+
enum Localization {
77+
static let wrongCredentials = NSLocalizedString(
78+
"It looks like this username/password isn't associated with this site.",
79+
comment: "An error message shown during login when the username or password is incorrect."
80+
)
81+
static let genericFailure = NSLocalizedString("Login failed. Please try again.", comment: "A generic error during site credential login")
82+
}
83+
84+
enum Constants {
85+
static let xmlrpcErrorDomain = "WPXMLRPCFaultError"
86+
static let invalidCredentialErrorCode = 403
87+
}
88+
}

WooCommerce/Classes/Authentication/Navigation Exceptions/JetpackSetupRequiredViewModel.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import UIKit
44
/// modeling an error when Jetpack is not installed or is not connected
55
/// Displayed as an entry point to the native Jetpack setup flow.
66
///
7-
struct JetpackSetupRequiredViewModel: ULErrorViewModel {
7+
final class JetpackSetupRequiredViewModel: ULErrorViewModel {
88
private let siteURL: String
9+
/// Whether Jetpack is installed and activated and only connection needs to be handled.
910
private let connectionOnly: Bool
1011
private let authentication: Authentication
1112
private let analytics: Analytics
13+
private var coordinator: LoginJetpackSetupCoordinator?
1214

1315
init(siteURL: String,
1416
connectionOnly: Bool,
@@ -84,7 +86,15 @@ struct JetpackSetupRequiredViewModel: ULErrorViewModel {
8486
}
8587

8688
func didTapPrimaryButton(in viewController: UIViewController?) {
87-
// TODO: handle Jetpack setup natively
89+
// TODO: add tracks
90+
guard let navigationController = viewController?.navigationController else {
91+
return
92+
}
93+
let coordinator = LoginJetpackSetupCoordinator(siteURL: siteURL,
94+
connectionOnly: connectionOnly,
95+
navigationController: navigationController)
96+
self.coordinator = coordinator
97+
coordinator.start()
8898
}
8999

90100
func didTapSecondaryButton(in viewController: UIViewController?) {

0 commit comments

Comments
 (0)