// // LoginViewController.swift // MullvadVPN // // Created by pronebird on 19/03/2019. // Copyright © 2025 Mullvad VPN AB. All rights reserved. // import MullvadLogging import MullvadTypes import Operations import UIKit enum LoginState { case `default` case authenticating(LoginAction) case failure(LoginAction, Error) case success(LoginAction) } enum LoginAction { case useExistingAccount(String) case createAccount } enum EndLoginAction { /// Do nothing. case nothing /// Set focus on account text field. case activateTextField /// Wait for promise before showing login error. case wait(Promise) } class LoginViewController: UIViewController, RootContainment { private lazy var contentView: LoginContentView = { let view = LoginContentView(frame: self.view.bounds) view.translatesAutoresizingMaskIntoConstraints = false return view }() private lazy var accountInputAccessoryCancelButton = UIBarButtonItem( barButtonSystemItem: .cancel, target: self, action: #selector(cancelLogin) ) private lazy var accountInputAccessoryLoginButton: UIBarButtonItem = { let barButtonItem = UIBarButtonItem( title: NSLocalizedString("Login", comment: ""), style: .done, target: self, action: #selector(doLogin) ) barButtonItem.setAccessibilityIdentifier(.loginBarButton) return barButtonItem }() private lazy var accountInputAccessoryToolbar: UIToolbar = { let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: 320, height: 44)) toolbar.items = [ self.accountInputAccessoryCancelButton, UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), self.accountInputAccessoryLoginButton, ] toolbar.sizeToFit() return toolbar }() private let logger = Logger(label: "LoginViewController") private var loginState = LoginState.default { didSet { loginStateDidChange() } } private var canBeginLogin: Bool { contentView.accountInputGroup.satisfiesMinimumTokenLengthRequirement } var prefersDeviceInfoBarHidden: Bool { true } private let interactor: LoginInteractor var didFinishLogin: ((LoginAction, Error?) -> EndLoginAction)? override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent } var preferredHeaderBarPresentation: HeaderBarPresentation { HeaderBarPresentation(style: .transparent, showsDivider: false) } var prefersHeaderBarHidden: Bool { false } private let alertPresenter: AlertPresenter init(interactor: LoginInteractor, alertPresenter: AlertPresenter) { self.interactor = interactor self.alertPresenter = alertPresenter super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() view.addSubview(contentView) NSLayoutConstraint.activate([ contentView.topAnchor.constraint(equalTo: view.topAnchor), contentView.leadingAnchor.constraint(equalTo: view.leadingAnchor), contentView.trailingAnchor.constraint(equalTo: view.trailingAnchor), contentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) updateLastUsedAccount() contentView.accountInputGroup.didRemoveLastUsedAccount = { [weak self] in self?.showLastUsedAccountRemovalWarning() } contentView.accountInputGroup.didEnterAccount = { [weak self] in self?.attemptLogin() } interactor.suggestPreferredAccountNumber = { [weak self] value in Task { @MainActor in self?.contentView.accountInputGroup.setAccount(value) } } contentView.accountInputGroup.setOnReturnKey { [weak self] _ in guard let self else { return true } return attemptLogin() } // There is no need to set the input accessory toolbar on iPad since it has a dedicated // button to dismiss the keyboard. if case .phone = UIDevice.current.userInterfaceIdiom { contentView.accountInputGroup.textField.inputAccessoryView = accountInputAccessoryToolbar } else { contentView.accountInputGroup.textField.inputAccessoryView = nil } updateDisplayedMessage() updateStatusIcon() updateKeyboardToolbar() let notificationCenter = NotificationCenter.default contentView.createAccountButton.addTarget( self, action: #selector(createNewAccount), for: .touchUpInside ) notificationCenter.addObserver( self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: contentView.accountInputGroup.textField ) } // MARK: - Public func start(action: LoginAction) { beginLogin(action) Task { [weak self] in guard let self else { return } do { switch action { case .createAccount: self.contentView.accountInputGroup.setAccount(try await interactor.createAccount()) case let .useExistingAccount(accountNumber): try await interactor.setAccount(accountNumber: accountNumber) } self.endLogin(action: action, error: nil) } catch { self.endLogin(action: action, error: error) } } } func reset() { contentView.accountInputGroup.clearAccount() loginState = .default updateKeyboardToolbar() updateLastUsedAccount() } // MARK: - UITextField notifications @objc func textDidChange(_ notification: Notification) { // Reset the text style as user start typing if case .failure = loginState { loginState = .default } // Enable the log in button in the keyboard toolbar. updateKeyboardToolbar() // Update "create account" button state. updateCreateButtonEnabled() } // MARK: - Actions @objc private func cancelLogin() { view.endEditing(true) } @objc private func doLogin() { let accountNumber = contentView.accountInputGroup.parsedToken start(action: .useExistingAccount(accountNumber)) } @objc private func createNewAccount() { if interactor.hasLastAccountNumber { let message = NSMutableAttributedString( markdownString: [ NSLocalizedString( "You already have a saved account number, by creating a new account the " + "saved account number will be removed from this device. This cannot be undone.", comment: "" ), NSLocalizedString("Do you want to create a new account?", comment: ""), ].joinedParagraphs(lineBreaks: 1), options: MarkdownStylingOptions(font: .preferredFont(forTextStyle: .body)) ) let presentation = AlertPresentation( id: "create-account-confirmation-dialog", icon: .info, attributedMessage: message, buttons: [ AlertAction( title: NSLocalizedString("Create new account", comment: ""), style: .default, accessibilityId: .createAccountConfirmationButton, handler: { self.start(action: .createAccount) } ), AlertAction( title: NSLocalizedString("Cancel", comment: ""), style: .default, accessibilityId: .createAccountCancelButton ), ] ) alertPresenter.showAlert(presentation: presentation, animated: true) } else { start(action: .createAccount) } } // MARK: - Private private func showLastUsedAccountRemovalWarning() { let message = NSMutableAttributedString( markdownString: NSLocalizedString( """ Removing the saved account number from this device cannot be undone. Do you want to remove the saved account number? """, comment: "" ), options: MarkdownStylingOptions(font: .preferredFont(forTextStyle: .body)) ) let presentation = AlertPresentation( id: "remove-saved-account-number-dialog", icon: .info, attributedMessage: message, buttons: [ AlertAction( title: NSLocalizedString("Remove", comment: ""), style: .destructive, accessibilityId: .removeLastUsedAccountButton, handler: { self.interactor.removeLastUsedAccount() self.contentView.accountInputGroup.setLastUsedAccount( nil, animated: true ) } ), AlertAction( title: NSLocalizedString("Cancel", comment: ""), style: .default, accessibilityId: .cancelRemoveLastUsedAccountButton ), ] ) self.alertPresenter.showAlert(presentation: presentation, animated: true) } private func updateLastUsedAccount() { contentView.accountInputGroup.setLastUsedAccount( interactor.getLastUsedAccount(), animated: false ) } private func loginStateDidChange() { contentView.accountInputGroup.setLoginState(loginState, animated: true) updateDisplayedMessage() updateStatusIcon() updateCreateButtonEnabled() } private func updateStatusIcon() { contentView.statusActivityView.state = loginState.statusActivityState } private func beginLogin(_ action: LoginAction) { loginState = .authenticating(action) view.endEditing(true) } private func endLogin(action: LoginAction, error: Error?) { let nextLoginState: LoginState = error.map { .failure(action, $0) } ?? .success(action) let endAction = didFinishLogin?(action, error) ?? .nothing switch endAction { case .activateTextField: contentView.accountInputGroup.textField.becomeFirstResponder() loginState = nextLoginState case .nothing: loginState = nextLoginState case let .wait(promise): promise.observe { result in self.loginState = result.error.map { .failure(action, $0) } ?? nextLoginState } } } private func updateDisplayedMessage() { contentView.titleLabel.text = loginState.localizedTitle contentView.messageLabel.text = loginState.localizedMessage } private func updateKeyboardToolbar() { accountInputAccessoryLoginButton.isEnabled = canBeginLogin } private func updateCreateButtonEnabled() { let isEnabled: Bool switch loginState { case .failure, .default: isEnabled = true case .success, .authenticating: isEnabled = false } contentView.createAccountButton.isEnabled = isEnabled } @discardableResult private func attemptLogin() -> Bool { if canBeginLogin { doLogin() return true } else { return false } } } /// Private extension that brings localizable messages displayed in the Login view controller private extension LoginState { var localizedTitle: String { switch self { case .default: return NSLocalizedString("Login", comment: "") case .authenticating: return NSLocalizedString("Logging in...", comment: "") case .failure: return NSLocalizedString("Login failed", comment: "") case .success: return NSLocalizedString("Logged in", comment: "") } } var localizedMessage: String { switch self { case .default: return NSLocalizedString("Enter your account number", comment: "") case let .authenticating(method): switch method { case .useExistingAccount: return NSLocalizedString("Checking account number", comment: "") case .createAccount: return NSLocalizedString("Creating account...", comment: "") } case let .failure(_, error): return (error as? DisplayError)?.displayErrorDescription ?? error.localizedDescription case let .success(method): switch method { case .useExistingAccount: return NSLocalizedString("Valid account number", comment: "") case .createAccount: return NSLocalizedString("Account created", comment: "") } } } var statusActivityState: StatusActivityView.State { switch self { case .failure: return .failure case .success: return .success case .authenticating: return .activity case .default: return .hidden } } } // swiftlint:disable:this file_length