diff --git a/Lock.xcodeproj/project.pbxproj b/Lock.xcodeproj/project.pbxproj index f66d3cb7c..2b4f5df14 100644 --- a/Lock.xcodeproj/project.pbxproj +++ b/Lock.xcodeproj/project.pbxproj @@ -522,8 +522,8 @@ 5BB4A7C01DF9A38E008E8C37 /* DatabaseView.swift */, 5F1C49921D8360DF005B74FC /* LoadingView.swift */, 5B55F3C81E24273D00B75CF5 /* UnrecoverableErrorView.swift */, - 5F73CDD31D3073BE00D8D8D1 /* DatabaseForgotPasswordView.swift */, 5B5F9F9E1E4B3FBE00EAB9EE /* PasswordlessView.swift */, + 5F73CDD31D3073BE00D8D8D1 /* DatabaseForgotPasswordView.swift */, 5B0971811DC8FAC5003AA88F /* EnterpriseDomainView.swift */, 5B4DE0181DD670F7004C8AC2 /* EnterpriseActiveAuthView.swift */, 5F50900D1D1DF40400EAA650 /* DatabaseOnlyView.swift */, diff --git a/Lock/Base.lproj/Lock.strings b/Lock/Base.lproj/Lock.strings index bedb6cd10..29ec31317 100644 --- a/Lock/Base.lproj/Lock.strings +++ b/Lock/Base.lproj/Lock.strings @@ -68,6 +68,12 @@ "com.auth0.lock.error.passwordless.invalid_link" = "WE'RE SORRY, THERE WAS A PROBLEM WITH YOUR LINK. PLEASE REQUEST A NEW ONE."; // Passwordless sign ups disabled. "com.auth0.lock.error.passwordless.signup_disabled" = "NEW SIGN UPS ARE DISABLED FOR THIS ACCOUNT, PLEASE CONTACT YOUR ADMINISTRATOR."; +// Recoverable error button +"com.auth0.lock.error.recoverable.button" = "Retry"; +// Recoverable error message +"com.auth0.lock.error.recoverable.message" = "Please check your internet connection."; +// Recoverable error title +"com.auth0.lock.error.recoverable.title" = "Can't load the login box"; // Generic sign up error "com.auth0.lock.error.signup.fallback" = "WE'RE SORRY, SOMETHING WENT WRONG WHEN ATTEMPTING TO SIGN UP."; // invalid_password @@ -80,14 +86,22 @@ "com.auth0.lock.error.signup.password_no_user_info_error" = "PASSWORD IS BASED ON USER INFORMATION."; // password_strength_error "com.auth0.lock.error.signup.password_strength_error" = "PASSWORD IS TOO WEAK."; +// Unrecoverable error button +"com.auth0.lock.error.unrecoverable.button" = "Contact support"; // Default error "com.auth0.lock.error.unrecoverable.default" = "Something went wrong.\nPlease contact technical support."; // Invalid client or domain "com.auth0.lock.error.unrecoverable.invalid_credentials" = "Your Auth0 credentials ClientId and/or Domain are invalid."; // Your options configuration failed with: %@{error} "com.auth0.lock.error.unrecoverable.invalid_options" = "Your options configuration failed with: %1$@"; +// Unrecoverable error message +"com.auth0.lock.error.unrecoverable.message" = "There was an unexpected error while resolving the login box configuration."; +// Unrecoverable error message +"com.auth0.lock.error.unrecoverable.message.no_action" = "There was an unexpected error while resolving the login box configuration, please contact support."; // No connections "com.auth0.lock.error.unrecoverable.no_connections" = "No authentication methods found for this client. please check your client setup."; +// Unrecoverable error title +"com.auth0.lock.error.unrecoverable.title" = "Can't resolve your request"; // Forgot Password message "com.auth0.lock.forgot.message" = "Please enter your email and the new password. We will send you an email to confirm the password change."; // Forgot Password title @@ -208,8 +222,6 @@ "com.auth0.lock.strategy.signup.title" = "SIGN UP WITH %1$@"; // Login Button title "com.auth0.lock.submit.login.title" = "LOG IN"; -// Retry -"com.auth0.lock.submit.retry.title" = "RETRY"; // Send 2fa code "com.auth0.lock.submit.send_code.title" = "SEND"; // Send Email button title diff --git a/Lock/Lock.xcassets/ic_connection_error.imageset/Contents.json b/Lock/Lock.xcassets/ic_connection_error.imageset/Contents.json new file mode 100644 index 000000000..295f3237c --- /dev/null +++ b/Lock/Lock.xcassets/ic_connection_error.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "ic_connection_error.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Lock/Lock.xcassets/ic_connection_error.imageset/ic_connection_error.pdf b/Lock/Lock.xcassets/ic_connection_error.imageset/ic_connection_error.pdf new file mode 100644 index 000000000..faf8190d9 Binary files /dev/null and b/Lock/Lock.xcassets/ic_connection_error.imageset/ic_connection_error.pdf differ diff --git a/Lock/LockOptions.swift b/Lock/LockOptions.swift index 17bef812b..9c3293b13 100644 --- a/Lock/LockOptions.swift +++ b/Lock/LockOptions.swift @@ -27,6 +27,7 @@ struct LockOptions: OptionBuildable { var closable: Bool = false var termsOfServiceURL: URL = URL(string: "https://auth0.com/terms")! var privacyPolicyURL: URL = URL(string: "https://auth0.com/privacy")! + var supportURL: URL? var logLevel: LoggerLevel = .off var loggerOutput: LoggerOutput? var logHttpRequest: Bool = false diff --git a/Lock/OptionBuildable.swift b/Lock/OptionBuildable.swift index fc3c15df9..159e3d724 100644 --- a/Lock/OptionBuildable.swift +++ b/Lock/OptionBuildable.swift @@ -36,6 +36,9 @@ public protocol OptionBuildable: Options { /// Privacy Policy URL. By default is Auth0's. var privacyPolicyURL: URL { get set } + /// Support page url that will be displayed (Inside Safari) when an unrecoverable error occurs and the user taps the "Contact Support" button in the error screen. + var supportURL: URL? { get set } + /// Log level for Lock. By default is `Off`. var logLevel: LoggerLevel { get set } @@ -136,4 +139,16 @@ public extension OptionBuildable { } } + /// Support Page URL. By default is not set. + var supportPage: String? { + get { + guard let url = self.supportURL else { return nil } + return url.absoluteString + } + set { + guard let value = newValue, let url = URL(string: value) else { return } // FIXME: log error + self.supportURL = url + } + } + } diff --git a/Lock/Options.swift b/Lock/Options.swift index 554bdb68a..1e5bc6805 100644 --- a/Lock/Options.swift +++ b/Lock/Options.swift @@ -27,6 +27,7 @@ public protocol Options { var termsOfServiceURL: URL { get } var privacyPolicyURL: URL { get } + var supportURL: URL? { get } var logLevel: LoggerLevel { get } var loggerOutput: LoggerOutput? { get } diff --git a/Lock/Router.swift b/Lock/Router.swift index 6f0632ab8..4bc841504 100644 --- a/Lock/Router.swift +++ b/Lock/Router.swift @@ -57,7 +57,8 @@ extension Router { } func unrecoverableError(for error: UnrecoverableError) -> Presentable? { - let presenter = UnrecoverableErrorPresenter(error: error, navigator: self) + guard let options = self.controller?.lock.options else { return nil } + let presenter = UnrecoverableErrorPresenter(error: error, navigator: self, options: options) return presenter } } diff --git a/Lock/UnrecoverableError.swift b/Lock/UnrecoverableError.swift index 56d6e01b7..be44bbe8d 100644 --- a/Lock/UnrecoverableError.swift +++ b/Lock/UnrecoverableError.swift @@ -42,6 +42,15 @@ enum UnrecoverableError: Equatable, Error { return "Something went wrong.\nPlease contact technical support.".i18n(key: "com.auth0.lock.error.unrecoverable.default", comment: "Default error") } } + + var canRetry: Bool { + switch self { + case .connectionTimeout, .requestIssue: + return true + default: + return false + } + } } func == (lhs: UnrecoverableError, rhs: UnrecoverableError) -> Bool { diff --git a/Lock/UnrecoverableErrorPresenter.swift b/Lock/UnrecoverableErrorPresenter.swift index 340395b4c..0721a70e8 100644 --- a/Lock/UnrecoverableErrorPresenter.swift +++ b/Lock/UnrecoverableErrorPresenter.swift @@ -22,21 +22,31 @@ import Foundation -class UnrecoverableErrorPresenter: Presentable, Loggable { +class UnrecoverableErrorPresenter: Presentable { let navigator: Navigable let error: UnrecoverableError + let options: Options var messagePresenter: MessagePresenter? - init(error: UnrecoverableError, navigator: Navigable) { + init(error: UnrecoverableError, navigator: Navigable, options: Options) { self.navigator = navigator self.error = error + self.options = options } var view: View { - let view = UnrecoverableErrorView(message: self.error.localizableMessage) - view.primaryButton?.onPress = { _ in - self.navigator.navigate(.root) + let view = UnrecoverableErrorView(canRetry: self.error.canRetry) + if self.error.canRetry { + view.secondaryButton?.onPress = { _ in + self.navigator.navigate(.root) + } + } else if let supportURL = self.options.supportURL { + view.secondaryButton?.onPress = { _ in + UIApplication.shared.openURL(supportURL) + } + view.secondaryButton?.isHidden = false + view.messageLabel?.text = "There was an unexpected error while resolving the login box configuration.".i18n(key: "com.auth0.lock.error.unrecoverable.message", comment: "Unrecoverable error message") } return view } diff --git a/Lock/UnrecoverableErrorView.swift b/Lock/UnrecoverableErrorView.swift index 30efc5de2..ceba97a17 100644 --- a/Lock/UnrecoverableErrorView.swift +++ b/Lock/UnrecoverableErrorView.swift @@ -24,43 +24,71 @@ import UIKit class UnrecoverableErrorView: UIView, View { - weak var primaryButton: PrimaryButton? - weak var label: UILabel? + weak var secondaryButton: SecondaryButton? + weak var messageLabel: UILabel? - init(message: String) { - let primaryButton = PrimaryButton() + init(canRetry: Bool) { let center = UILayoutGuide() - let label = UILabel() - - self.primaryButton = primaryButton - self.label = label + let titleLabel = UILabel() + let messageLabel = UILabel() + let imageView = UIImageView() + let actionButton = SecondaryButton() + self.secondaryButton = actionButton + self.messageLabel = messageLabel super.init(frame: CGRect.zero) - self.addSubview(primaryButton) - self.addSubview(label) + self.addSubview(imageView) + self.addSubview(titleLabel) + self.addSubview(messageLabel) + self.addSubview(actionButton) self.addLayoutGuide(center) - constraintEqual(anchor: center.leftAnchor, toAnchor: self.leftAnchor, constant: 20) + constraintEqual(anchor: center.leftAnchor, toAnchor: self.leftAnchor) constraintEqual(anchor: center.topAnchor, toAnchor: self.topAnchor) - constraintEqual(anchor: center.rightAnchor, toAnchor: self.rightAnchor, constant: -20) - constraintEqual(anchor: center.bottomAnchor, toAnchor: primaryButton.topAnchor) - - constraintEqual(anchor: label.leftAnchor, toAnchor: center.leftAnchor) - constraintEqual(anchor: label.rightAnchor, toAnchor: center.rightAnchor) - constraintEqual(anchor: label.centerYAnchor, toAnchor: center.centerYAnchor, constant: -20) - label.translatesAutoresizingMaskIntoConstraints = false - - constraintEqual(anchor: primaryButton.leftAnchor, toAnchor: self.leftAnchor) - constraintEqual(anchor: primaryButton.rightAnchor, toAnchor: self.rightAnchor) - constraintEqual(anchor: primaryButton.bottomAnchor, toAnchor: self.bottomAnchor) - primaryButton.translatesAutoresizingMaskIntoConstraints = false - - label.text = message - label.textAlignment = .center - label.numberOfLines = 3 - label.font = mediumSystemFont(size: 16) - primaryButton.title = "RETRY".i18n(key: "com.auth0.lock.submit.retry.title", comment: "Retry") + constraintEqual(anchor: center.rightAnchor, toAnchor: self.rightAnchor) + constraintEqual(anchor: center.bottomAnchor, toAnchor: self.bottomAnchor) + + constraintEqual(anchor: imageView.centerXAnchor, toAnchor: center.centerXAnchor) + constraintEqual(anchor: imageView.centerYAnchor, toAnchor: center.centerYAnchor, constant: -90) + imageView.translatesAutoresizingMaskIntoConstraints = false + + constraintEqual(anchor: titleLabel.leftAnchor, toAnchor: self.leftAnchor, constant: 20) + constraintEqual(anchor: titleLabel.rightAnchor, toAnchor: self.rightAnchor, constant: -20) + constraintEqual(anchor: titleLabel.centerYAnchor, toAnchor: center.centerYAnchor, constant: -15) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + constraintEqual(anchor: messageLabel.leftAnchor, toAnchor: self.leftAnchor, constant: 20) + constraintEqual(anchor: messageLabel.rightAnchor, toAnchor: self.rightAnchor, constant: -20) + constraintEqual(anchor: messageLabel.topAnchor, toAnchor: titleLabel.bottomAnchor, constant: 15) + messageLabel.translatesAutoresizingMaskIntoConstraints = false + + constraintEqual(anchor: actionButton.centerXAnchor, toAnchor: center.centerXAnchor) + constraintEqual(anchor: actionButton.topAnchor, toAnchor: messageLabel.bottomAnchor, constant: 10) + actionButton.translatesAutoresizingMaskIntoConstraints = false + + imageView.image = LazyImage(name: "ic_connection_error", bundle: bundleForLock()).image(compatibleWithTraits: self.traitCollection) + titleLabel.textAlignment = .center + titleLabel.font = lightSystemFont(size: 22) + titleLabel.numberOfLines = 1 + messageLabel.textAlignment = .center + messageLabel.font = regularSystemFont(size: 15) + messageLabel.textColor = UIColor(red: 0.408, green: 0.408, blue: 0.408, alpha: 1.00) + messageLabel.numberOfLines = 3 + + actionButton.button?.setTitleColor(UIColor(red:0.04, green:0.53, blue:0.69, alpha:1.0), for: .normal) + actionButton.button?.titleLabel?.font = regularSystemFont(size: 16) + + if canRetry { + titleLabel.text = "Can't load the login box".i18n(key: "com.auth0.lock.error.recoverable.title", comment: "Recoverable error title") + messageLabel.text = "Please check your internet connection.".i18n(key: "com.auth0.lock.error.recoverable.message", comment: "Recoverable error message") + actionButton.title = "Retry".i18n(key: "com.auth0.lock.error.recoverable.button", comment: "Recoverable error button") + } else { + titleLabel.text = "Can't resolve your request".i18n(key: "com.auth0.lock.error.unrecoverable.title", comment: "Unrecoverable error title") + messageLabel.text = "There was an unexpected error while resolving the login box configuration, please contact support.".i18n(key: "com.auth0.lock.error.unrecoverable.message.no_action", comment: "Unrecoverable error message") + actionButton.title = "Contact support".i18n(key: "com.auth0.lock.error.unrecoverable.button", comment: "Unrecoverable error button") + actionButton.isHidden = true + } } required init?(coder aDecoder: NSCoder) { @@ -68,6 +96,5 @@ class UnrecoverableErrorView: UIView, View { } func apply(style: Style) { - self.primaryButton?.apply(style: style) } } diff --git a/LockTests/OptionsSpec.swift b/LockTests/OptionsSpec.swift index fb2826fee..2065883ba 100644 --- a/LockTests/OptionsSpec.swift +++ b/LockTests/OptionsSpec.swift @@ -57,6 +57,14 @@ class OptionsSpec: QuickSpec { expect(options.privacyPolicyURL.absoluteString) == "https://auth0.com/privacy" } + it("should have Auth0 support as nil") { + expect(options.supportURL).to(beNil()) + } + + it("should return Auth0 supportPage as nil") { + expect(options.supportPage).to(beNil()) + } + it("should have openid as scope") { expect(options.scope) == "openid" } @@ -223,6 +231,16 @@ class OptionsSpec: QuickSpec { options.privacyPolicy = "not a url" expect(options.privacyPolicyURL.absoluteString) == "https://auth0.com/privacy" } + + it("should set support site") { + options.supportPage = "https://auth0.com/docs" + expect(options.supportURL?.absoluteString) == "https://auth0.com/docs" + } + + it("should ignore invalid support site") { + options.supportPage = "not a url" + expect(options.supportURL?.absoluteString).to(beNil()) + } } } } diff --git a/LockTests/Presenters/UnrecoverableErrorPresenterSpec.swift b/LockTests/Presenters/UnrecoverableErrorPresenterSpec.swift index c9533313b..529debddb 100644 --- a/LockTests/Presenters/UnrecoverableErrorPresenterSpec.swift +++ b/LockTests/Presenters/UnrecoverableErrorPresenterSpec.swift @@ -34,11 +34,13 @@ class UnrecoverableErrorPresenterSpec: QuickSpec { var navigator: MockNavigator! var error: UnrecoverableError! var view: UnrecoverableErrorView! + var options: OptionBuildable! beforeEach { + options = LockOptions() error = UnrecoverableError.connectionTimeout navigator = MockNavigator() - presenter = UnrecoverableErrorPresenter(error: error, navigator: navigator) + presenter = UnrecoverableErrorPresenter(error: error, navigator: navigator, options: options) view = presenter.view as? UnrecoverableErrorView } @@ -48,23 +50,77 @@ class UnrecoverableErrorPresenterSpec: QuickSpec { expect(presenter.view as? UnrecoverableErrorView).toNot(beNil()) } - it("should have button title") { - expect(view.primaryButton?.title) == "RETRY" + context("retry error") { + + beforeEach { + presenter = UnrecoverableErrorPresenter(error: .connectionTimeout, navigator: navigator, options: options) + view = presenter.view as? UnrecoverableErrorView + } + + it("should have relevant retry button title") { + expect(view.secondaryButton?.title?.contains("Retry")) == true + } } - it("should display relevant error message") { - expect(view.label?.text) == error.localizableMessage + context("support error with no page (default)") { + + beforeEach { + presenter = UnrecoverableErrorPresenter(error: .invalidClientOrDomain, navigator: navigator, options: options) + view = presenter.view as? UnrecoverableErrorView + } + + it("should not display support button") { + expect(view.secondaryButton?.isHidden) == true + } + } + + context("support error with support page provided") { + + beforeEach { + options.supportPage = "http://auth0.com/docs" + presenter = UnrecoverableErrorPresenter(error: .invalidClientOrDomain, navigator: navigator, options: options) + view = presenter.view as? UnrecoverableErrorView + } + + it("should have a support button title") { + expect(view.secondaryButton?.title?.contains("Contact")) == true + } + + it("should have a visible support button") { + expect(view.secondaryButton?.isHidden) == false + } } } describe("action") { - it("should trigger retry on button press") { - view.primaryButton?.onPress(view.primaryButton!) - expect(navigator.route) == Route.root + context("retry error") { + + beforeEach { + presenter = UnrecoverableErrorPresenter(error: .connectionTimeout, navigator: navigator, options: options) + view = presenter.view as? UnrecoverableErrorView + } + + it("should trigger retry on button press") { + view.secondaryButton?.onPress(view.secondaryButton!) + expect(navigator.route) == Route.root + } } - } + context("support error") { + beforeEach { + presenter = UnrecoverableErrorPresenter(error: .invalidClientOrDomain, navigator: navigator, options: options) + view = presenter.view as? UnrecoverableErrorView + } + + it("should not trigger retry on button press") { + view.secondaryButton?.onPress(view.secondaryButton!) + expect(navigator.route).to(beNil()) + } + } + + } + } }