diff --git a/Riot/Assets/en.lproj/Untranslated.strings b/Riot/Assets/en.lproj/Untranslated.strings index 1b9b8518b..49ddc2243 100644 --- a/Riot/Assets/en.lproj/Untranslated.strings +++ b/Riot/Assets/en.lproj/Untranslated.strings @@ -23,13 +23,17 @@ // MARK: Onboarding Authentication WIP "authentication_registration_title" = "Create your account"; "authentication_registration_message" = "We’ll need some info to get you set up."; -"authentication_registration_server_title" = "Choose your server to store your data"; -"authentication_registration_matrix_description" = "Join millions for free on the largest public server"; "authentication_registration_username" = "Username"; -"authentication_registration_password" = "Password"; "authentication_registration_username_footer" = "You can’t change this later"; "authentication_registration_password_footer" = "Must be 8 characters or more"; +"authentication_login_title" = "Welcome back!"; +"authentication_login_username" = "Username or Email"; +"authentication_login_forgot_password" = "Forgot password"; + +"authentication_server_info_title" = "Choose your server to store your data"; +"authentication_server_info_matrix_description" = "Join millions for free on the largest public server"; + "authentication_server_selection_title" = "Choose your server"; "authentication_server_selection_message" = "What is the address of your server? A server is like a home for all your data."; "authentication_server_selection_server_url" = "Server URL"; diff --git a/Riot/Categories/UIDevice.swift b/Riot/Categories/UIDevice.swift index 093a8bdee..c3405e8ed 100644 --- a/Riot/Categories/UIDevice.swift +++ b/Riot/Categories/UIDevice.swift @@ -34,4 +34,8 @@ import UIKit return userInterfaceIdiom == .phone } + var initialDisplayName: String { + isPhone ? VectorL10n.loginMobileDevice : VectorL10n.loginTabletDevice + } + } diff --git a/Riot/Generated/UntranslatedStrings.swift b/Riot/Generated/UntranslatedStrings.swift index ce6960ade..a78be54f4 100644 --- a/Riot/Generated/UntranslatedStrings.swift +++ b/Riot/Generated/UntranslatedStrings.swift @@ -14,30 +14,30 @@ public extension VectorL10n { static var authenticationCancelFlowConfirmationMessage: String { return VectorL10n.tr("Untranslated", "authentication_cancel_flow_confirmation_message") } + /// Forgot password + static var authenticationLoginForgotPassword: String { + return VectorL10n.tr("Untranslated", "authentication_login_forgot_password") + } + /// Welcome back! + static var authenticationLoginTitle: String { + return VectorL10n.tr("Untranslated", "authentication_login_title") + } + /// Username or Email + static var authenticationLoginUsername: String { + return VectorL10n.tr("Untranslated", "authentication_login_username") + } /// This server would like to make sure you are not a robot static var authenticationRecaptchaMessage: String { return VectorL10n.tr("Untranslated", "authentication_recaptcha_message") } - /// Join millions for free on the largest public server - static var authenticationRegistrationMatrixDescription: String { - return VectorL10n.tr("Untranslated", "authentication_registration_matrix_description") - } /// We’ll need some info to get you set up. static var authenticationRegistrationMessage: String { return VectorL10n.tr("Untranslated", "authentication_registration_message") } - /// Password - static var authenticationRegistrationPassword: String { - return VectorL10n.tr("Untranslated", "authentication_registration_password") - } /// Must be 8 characters or more static var authenticationRegistrationPasswordFooter: String { return VectorL10n.tr("Untranslated", "authentication_registration_password_footer") } - /// Choose your server to store your data - static var authenticationRegistrationServerTitle: String { - return VectorL10n.tr("Untranslated", "authentication_registration_server_title") - } /// Create your account static var authenticationRegistrationTitle: String { return VectorL10n.tr("Untranslated", "authentication_registration_title") @@ -50,6 +50,14 @@ public extension VectorL10n { static var authenticationRegistrationUsernameFooter: String { return VectorL10n.tr("Untranslated", "authentication_registration_username_footer") } + /// Join millions for free on the largest public server + static var authenticationServerInfoMatrixDescription: String { + return VectorL10n.tr("Untranslated", "authentication_server_info_matrix_description") + } + /// Choose your server to store your data + static var authenticationServerInfoTitle: String { + return VectorL10n.tr("Untranslated", "authentication_server_info_title") + } /// Cannot find a server at this URL, please check it is correct. static var authenticationServerSelectionGenericError: String { return VectorL10n.tr("Untranslated", "authentication_server_selection_generic_error") diff --git a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift index 80829fae7..f79e1d5ef 100644 --- a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift +++ b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift @@ -144,6 +144,43 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc callback?(.cancel(.register)) } + // MARK: - Login + + /// Shows the login screen. + @MainActor private func showLoginScreen() { + MXLog.debug("[AuthenticationCoordinator] showLoginScreen") + + let homeserver = authenticationService.state.homeserver + let parameters = AuthenticationLoginCoordinatorParameters(navigationRouter: navigationRouter, + authenticationService: authenticationService, + loginMode: homeserver.preferredLoginMode) + let coordinator = AuthenticationLoginCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] result in + guard let self = self, let coordinator = coordinator else { return } + self.loginCoordinator(coordinator, didCompleteWith: result) + } + + coordinator.start() + add(childCoordinator: coordinator) + + if navigationRouter.modules.isEmpty { + navigationRouter.setRootModule(coordinator, popCompletion: nil) + } else { + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + } + + /// Displays the next view in the flow after the registration screen. + @MainActor private func loginCoordinator(_ coordinator: AuthenticationLoginCoordinator, + didCompleteWith result: AuthenticationLoginCoordinatorResult) { + switch result { + case .success(let session): + onSessionCreated(session: session, flow: .login) + } + } + // MARK: - Registration /// Pushes the server selection screen into the flow (other screens may also present it modally later). @@ -298,12 +335,6 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc } } - /// Shows the login screen. - @MainActor private func showLoginScreen() { - MXLog.debug("[AuthenticationCoordinator] showLoginScreen") - - } - // MARK: - Registration Handlers /// Determines the next screen to show from the flow result and pushes it. @MainActor private func handleRegistrationResult(_ result: RegistrationResult) { @@ -378,7 +409,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc verificationListener.start() self.verificationListener = verificationListener - #warning("Add authentication type to the new flow") + #warning("Add authentication type to the new flow.") callback?(.didLogin(session: session, authenticationFlow: flow, authenticationType: .other)) } @@ -397,7 +428,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc /// Present the key verification screen modally. private func presentCompleteSecurity() { guard let session = session else { - MXLog.error("[LegacyAuthenticationCoordinator] presentCompleteSecurity: Unable to present security due to missing session.") + MXLog.error("[AuthenticationCoordinator] presentCompleteSecurity: Unable to present security due to missing session.") authenticationDidComplete() return } @@ -427,7 +458,7 @@ extension AuthenticationCoordinator: KeyVerificationCoordinatorDelegate { func keyVerificationCoordinatorDidComplete(_ coordinator: KeyVerificationCoordinatorType, otherUserId: String, otherDeviceId: String) { if let crypto = session?.crypto, !crypto.backup.hasPrivateKeyInCryptoStore || !crypto.backup.enabled { - MXLog.debug("[LegacyAuthenticationCoordinator][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys") + MXLog.debug("[AuthenticationCoordinator][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys") crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) } diff --git a/Riot/Modules/Onboarding/OnboardingCoordinator.swift b/Riot/Modules/Onboarding/OnboardingCoordinator.swift index 37882bec5..2a7996d0c 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinator.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinator.swift @@ -163,7 +163,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { case .register: showUseCaseSelectionScreen() case .login: - showLegacyAuthenticationScreen() + beginAuthentication(with: .login, onStart: coordinator.stop) } } @@ -232,6 +232,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { self.cancelAuthentication(flow: flow) } } + authenticationCoordinator = coordinator add(childCoordinator: coordinator) coordinator.start() @@ -256,7 +257,6 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { // These results are only sent by the new flow. break } - } // Due to needing to preload the authVC, this breaks the Coordinator init/start pattern. @@ -567,7 +567,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } guard authenticationFinished else { - MXLog.debug("[OnboardingCoordinator] Allowing LegacyAuthenticationCoordinator to display any remaining screens.") + MXLog.debug("[OnboardingCoordinator] Allowing AuthenticationCoordinator to display any remaining screens.") authenticationCoordinator.presentPendingScreensIfNecessary() return } diff --git a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationSSOButton.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationSSOButton.swift similarity index 100% rename from RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationSSOButton.swift rename to RiotSwiftUI/Modules/Authentication/Common/AuthenticationSSOButton.swift diff --git a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationServerInfoSection.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationServerInfoSection.swift new file mode 100644 index 000000000..63f5642f3 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationServerInfoSection.swift @@ -0,0 +1,65 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct AuthenticationServerInfoSection: View { + + // MARK: - Private + + @Environment(\.theme) private var theme + + // MARK: - Public + + let address: String + let description: String? + let editAction: () -> Void + + // MARK: - Views + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(VectorL10n.authenticationServerInfoTitle) + .font(theme.fonts.subheadline) + .foregroundColor(theme.colors.secondaryContent) + + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(address) + .font(theme.fonts.body) + .foregroundColor(theme.colors.primaryContent) + + if let description = description { + Text(description) + .font(theme.fonts.caption1) + .foregroundColor(theme.colors.tertiaryContent) + .accessibilityIdentifier("serverDescriptionText") + } + } + + Spacer() + + Button(action: editAction) { + Text(VectorL10n.edit) + .font(theme.fonts.body) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(theme.colors.accent)) + } + } + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift new file mode 100644 index 000000000..c3ca7ea45 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift @@ -0,0 +1,90 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +// MARK: View model + +enum AuthenticationLoginViewModelResult { + /// The user would like to select another server. + case selectServer + /// Parse the username and update the homeserver if included. + case parseUsername(String) + /// The user would like to reset their password. + case forgotPassword + /// Login using the supplied credentials. + case login(username: String, password: String) +} + +// MARK: View + +struct AuthenticationLoginViewState: BindableState { + /// The address of the homeserver. + var homeserverAddress: String + /// Whether or not to show the username and password text fields with the next button + var showLoginForm: Bool + /// An array containing the available SSO options for login. + var ssoIdentityProviders: [SSOIdentityProvider] + /// View state that can be bound to from SwiftUI. + var bindings: AuthenticationLoginBindings + + /// A description that can be shown for the currently selected homeserver. + var serverDescription: String? { + guard homeserverAddress == "matrix.org" else { return nil } + return VectorL10n.authenticationServerInfoMatrixDescription + } + + /// Whether to show any SSO buttons. + var showSSOButtons: Bool { + !ssoIdentityProviders.isEmpty + } + + /// `true` if it is possible to continue, otherwise `false`. + var hasValidCredentials: Bool { + !bindings.username.isEmpty && !bindings.password.isEmpty + } +} + +struct AuthenticationLoginBindings { + /// The username input by the user. + var username = "" + /// The password input by the user. + var password = "" + /// Information describing the currently displayed alert. + var alertInfo: AlertInfo? +} + +enum AuthenticationLoginViewAction { + /// The user would like to select another server. + case selectServer + /// Parse the username to detect if a homeserver is included. + case parseUsername + /// The user would like to reset their password. + case forgotPassword + /// Continue using the input username and password. + case next + /// Login using the supplied SSO provider ID. + case continueWithSSO(id: String) +} + +enum AuthenticationLoginErrorType: Hashable { + /// An error response from the homeserver. + case mxError(String) + /// The current homeserver address isn't valid. + case invalidHomeserver + /// The response from the homeserver was unexpected. + case unknown +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift new file mode 100644 index 000000000..aab6edbc2 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift @@ -0,0 +1,80 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +typealias AuthenticationLoginViewModelType = StateStoreViewModel + +class AuthenticationLoginViewModel: AuthenticationLoginViewModelType, AuthenticationLoginViewModelProtocol { + + // MARK: - Properties + + // MARK: Public + + @MainActor var callback: (@MainActor (AuthenticationLoginViewModelResult) -> Void)? + + // MARK: - Setup + + init(homeserverAddress: String, showLoginForm: Bool = true, ssoIdentityProviders: [SSOIdentityProvider]) { + let bindings = AuthenticationLoginBindings() + let viewState = AuthenticationLoginViewState(homeserverAddress: HomeserverAddress.displayable(homeserverAddress), + showLoginForm: showLoginForm, + ssoIdentityProviders: ssoIdentityProviders, + bindings: bindings) + + super.init(initialViewState: viewState) + } + + // MARK: - Public + + override func process(viewAction: AuthenticationLoginViewAction) { + switch viewAction { + case .selectServer: + Task { await callback?(.selectServer) } + case .parseUsername: + Task { await callback?(.parseUsername(state.bindings.username)) } + case .forgotPassword: + Task { await callback?(.forgotPassword) } + case .next: + Task { await callback?(.login(username: state.bindings.username, password: state.bindings.password)) } + case .continueWithSSO(let id): + break + } + } + + @MainActor func update(homeserverAddress: String, showLoginForm: Bool, ssoIdentityProviders: [SSOIdentityProvider]) { + state.homeserverAddress = HomeserverAddress.displayable(homeserverAddress) + state.showLoginForm = showLoginForm + state.ssoIdentityProviders = ssoIdentityProviders + } + + @MainActor func displayError(_ type: AuthenticationLoginErrorType) { + switch type { + case .mxError(let message): + state.bindings.alertInfo = AlertInfo(id: type, + title: VectorL10n.error, + message: message) + case .invalidHomeserver: + state.bindings.alertInfo = AlertInfo(id: type, + title: VectorL10n.error, + message: VectorL10n.authenticationServerSelectionGenericError) + case .unknown: + state.bindings.alertInfo = AlertInfo(id: type) + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift new file mode 100644 index 000000000..a9552cf4e --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModelProtocol.swift @@ -0,0 +1,33 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol AuthenticationLoginViewModelProtocol { + + @MainActor var callback: (@MainActor (AuthenticationLoginViewModelResult) -> Void)? { get set } + var context: AuthenticationLoginViewModelType.Context { get } + + /// Update the view with new homeserver information. + /// - Parameters: + /// - homeserverAddress: The homeserver string to be shown to the user. + /// - showLoginForm: Whether or not to display the username and password text fields. + /// - ssoIdentityProviders: The supported SSO login options. + @MainActor func update(homeserverAddress: String, showLoginForm: Bool, ssoIdentityProviders: [SSOIdentityProvider]) + + /// Display an error to the user. + @MainActor func displayError(_ type: AuthenticationLoginErrorType) +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift new file mode 100644 index 000000000..e0aef7afa --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift @@ -0,0 +1,231 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import CommonKit +import MatrixSDK + +struct AuthenticationLoginCoordinatorParameters { + let navigationRouter: NavigationRouterType + let authenticationService: AuthenticationService + /// The login mode to allow SSO buttons to be shown when available. + let loginMode: LoginMode +} + +enum AuthenticationLoginCoordinatorResult { + /// Login was successful with the associated session created. + case success(MXSession) +} + +final class AuthenticationLoginCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: AuthenticationLoginCoordinatorParameters + private let authenticationLoginHostingController: VectorHostingController + private var authenticationLoginViewModel: AuthenticationLoginViewModelProtocol + + private var currentTask: Task? { + willSet { + currentTask?.cancel() + } + } + + private var navigationRouter: NavigationRouterType { parameters.navigationRouter } + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var waitingIndicator: UserIndicator? + + /// The authentication service used for the login. + private var authenticationService: AuthenticationService { parameters.authenticationService } + /// The wizard used to handle the login flow. Will only be `nil` if there is a misconfiguration. + private var loginWizard: LoginWizard? { parameters.authenticationService.loginWizard } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + @MainActor var callback: ((AuthenticationLoginCoordinatorResult) -> Void)? + + // MARK: - Setup + + @MainActor init(parameters: AuthenticationLoginCoordinatorParameters) { + self.parameters = parameters + + let homeserver = parameters.authenticationService.state.homeserver + let viewModel = AuthenticationLoginViewModel(homeserverAddress: homeserver.addressFromUser ?? homeserver.address, + showLoginForm: homeserver.preferredLoginMode.supportsPasswordFlow, + ssoIdentityProviders: parameters.loginMode.ssoIdentityProviders ?? []) + authenticationLoginViewModel = viewModel + + let view = AuthenticationLoginScreen(viewModel: viewModel.context) + authenticationLoginHostingController = VectorHostingController(rootView: view) + authenticationLoginHostingController.vc_removeBackTitle() + authenticationLoginHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: authenticationLoginHostingController) + } + + // MARK: - Public + func start() { + MXLog.debug("[AuthenticationLoginCoordinator] did start.") + Task { await setupViewModel() } + } + + func toPresentable() -> UIViewController { + authenticationLoginHostingController + } + + // MARK: - Private + + /// Set up the view model. This method is extracted from `start()` so it can run on the `MainActor`. + @MainActor private func setupViewModel() { + authenticationLoginViewModel.callback = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[AuthenticationLoginCoordinator] AuthenticationLoginViewModel did callback with result: \(result).") + switch result { + case .selectServer: + self.presentServerSelectionScreen() + case .parseUsername(let username): + self.parseUsername(username) + case .forgotPassword: + #warning("Show the forgot password flow.") + case .login(let username, let password): + self.login(username: username, password: password) + } + } + } + + /// Show a blocking activity indicator whilst saving. + @MainActor private func startLoading(isInteractionBlocking: Bool) { + waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: isInteractionBlocking)) + } + + /// Hide the currently displayed activity indicator. + @MainActor private func stopLoading() { + waitingIndicator = nil + } + + /// Login with the supplied username and password. + @MainActor private func login(username: String, password: String) { + guard let loginWizard = loginWizard else { + MXLog.failure("[AuthenticationLoginCoordinator] The login wizard was requested before getting the login flow.") + return + } + + startLoading(isInteractionBlocking: true) + + currentTask = Task { [weak self] in + do { + let session = try await loginWizard.login(login: username, + password: password, + initialDeviceName: UIDevice.current.initialDisplayName) + + guard !Task.isCancelled else { return } + callback?(.success(session)) + + self?.stopLoading() + } catch { + self?.stopLoading() + self?.handleError(error) + } + } + } + + /// Processes an error to either update the flow or display it to the user. + @MainActor private func handleError(_ error: Error) { + if let mxError = MXError(nsError: error as NSError) { + authenticationLoginViewModel.displayError(.mxError(mxError.error)) + return + } + + if let authenticationError = error as? AuthenticationError { + switch authenticationError { + case .invalidHomeserver: + authenticationLoginViewModel.displayError(.invalidHomeserver) + case .loginFlowNotCalled: + #warning("Reset the flow") + case .missingMXRestClient: + #warning("Forget the soft logout session") + } + return + } + + authenticationLoginViewModel.displayError(.unknown) + } + + @MainActor private func parseUsername(_ username: String) { + guard MXTools.isMatrixUserIdentifier(username) else { return } + let domain = username.split(separator: ":")[1] + let homeserverAddress = HomeserverAddress.sanitized(String(domain)) + + startLoading(isInteractionBlocking: false) + + currentTask = Task { [weak self] in + do { + try await authenticationService.startFlow(.login, for: homeserverAddress) + + guard !Task.isCancelled else { return } + + updateViewModel() + self?.stopLoading() + } catch { + self?.stopLoading() + self?.handleError(error) + } + } + } + + /// Presents the server selection screen as a modal. + @MainActor private func presentServerSelectionScreen() { + MXLog.debug("[AuthenticationCoordinator] showServerSelectionScreen") + let parameters = AuthenticationServerSelectionCoordinatorParameters(authenticationService: authenticationService, + hasModalPresentation: true) + let coordinator = AuthenticationServerSelectionCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] result in + guard let self = self, let coordinator = coordinator else { return } + self.serverSelectionCoordinator(coordinator, didCompleteWith: result) + } + + coordinator.start() + add(childCoordinator: coordinator) + + let modalRouter = NavigationRouter() + modalRouter.setRootModule(coordinator) + + navigationRouter.present(modalRouter, animated: true) + } + + /// Handles the result from the server selection modal, dismissing it after updating the view. + @MainActor private func serverSelectionCoordinator(_ coordinator: AuthenticationServerSelectionCoordinator, + didCompleteWith result: AuthenticationServerSelectionCoordinatorResult) { + if result == .updated { + updateViewModel() + } + + navigationRouter.dismissModule(animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + + @MainActor private func updateViewModel() { + let homeserver = authenticationService.state.homeserver + authenticationLoginViewModel.update(homeserverAddress: homeserver.addressFromUser ?? homeserver.address, + showLoginForm: homeserver.preferredLoginMode.supportsPasswordFlow, + ssoIdentityProviders: homeserver.preferredLoginMode.ssoIdentityProviders ?? []) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/MockAuthenticationLoginScreenState.swift b/RiotSwiftUI/Modules/Authentication/Login/MockAuthenticationLoginScreenState.swift new file mode 100644 index 000000000..6b42c3856 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/MockAuthenticationLoginScreenState.swift @@ -0,0 +1,67 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockAuthenticationLoginScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case matrixDotOrg + case passwordOnly + case passwordWithCredentials + case ssoOnly + + /// The associated screen + var screenType: Any.Type { + AuthenticationLoginScreen.self + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel: AuthenticationLoginViewModel + switch self { + case .matrixDotOrg: + viewModel = AuthenticationLoginViewModel(homeserverAddress: "https://matrix.org", ssoIdentityProviders: [ + SSOIdentityProvider(id: "1", name: "Apple", brand: "apple", iconURL: nil), + SSOIdentityProvider(id: "2", name: "Facebook", brand: "facebook", iconURL: nil), + SSOIdentityProvider(id: "3", name: "GitHub", brand: "github", iconURL: nil), + SSOIdentityProvider(id: "4", name: "GitLab", brand: "gitlab", iconURL: nil), + SSOIdentityProvider(id: "5", name: "Google", brand: "google", iconURL: nil) + ]) + case .passwordOnly: + viewModel = AuthenticationLoginViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: []) + case .passwordWithCredentials: + viewModel = AuthenticationLoginViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: []) + viewModel.context.username = "alice" + viewModel.context.password = "password" + case .ssoOnly: + viewModel = AuthenticationLoginViewModel(homeserverAddress: "https://company.com", + showLoginForm: false, + ssoIdentityProviders: [SSOIdentityProvider(id: "test", name: "SAML", brand: nil, iconURL: nil)]) + } + + + // can simulate service and viewModel actions here if needs be. + + return ( + [viewModel], AnyView(AuthenticationLoginScreen(viewModel: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/Test/UI/AuthenticationLoginUITests.swift b/RiotSwiftUI/Modules/Authentication/Login/Test/UI/AuthenticationLoginUITests.swift new file mode 100644 index 000000000..243f40cce --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/Test/UI/AuthenticationLoginUITests.swift @@ -0,0 +1,44 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import RiotSwiftUI + +class AuthenticationLoginUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockAuthenticationLoginScreenState.self + } + + override class func createTest() -> MockScreenTest { + return AuthenticationLoginUITests(selector: #selector(verifyAuthenticationLoginScreen)) + } + + func verifyAuthenticationLoginScreen() throws { + guard let screenState = screenState as? MockAuthenticationLoginScreenState else { fatalError("no screen") } + switch screenState { + case .promptType(let promptType): + verifyAuthenticationLoginPromptType(promptType: promptType) + } + } + + func verifyAuthenticationLoginPromptType(promptType: AuthenticationLoginPromptType) { + let title = app.staticTexts["title"] + XCTAssert(title.exists) + XCTAssertEqual(title.label, promptType.title) + } + +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/Test/Unit/AuthenticationLoginViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/Login/Test/Unit/AuthenticationLoginViewModelTests.swift new file mode 100644 index 000000000..2635c4325 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/Test/Unit/AuthenticationLoginViewModelTests.swift @@ -0,0 +1,48 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +@testable import RiotSwiftUI + +class AuthenticationLoginViewModelTests: XCTestCase { + private enum Constants { + static let counterInitialValue = 0 + } + + var viewModel: AuthenticationLoginViewModelProtocol! + var context: AuthenticationLoginViewModelType.Context! + + override func setUpWithError() throws { + viewModel = AuthenticationLoginViewModel(promptType: .regular, initialCount: Constants.counterInitialValue) + context = viewModel.context + } + + func testInitialState() { + XCTAssertEqual(context.viewState.count, Constants.counterInitialValue) + } + + func testCounter() throws { + context.send(viewAction: .incrementCount) + XCTAssertEqual(context.viewState.count, 1) + + context.send(viewAction: .incrementCount) + XCTAssertEqual(context.viewState.count, 2) + + context.send(viewAction: .decrementCount) + XCTAssertEqual(context.viewState.count, 1) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift new file mode 100644 index 000000000..47ef0845d --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift @@ -0,0 +1,172 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct AuthenticationLoginScreen: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + /// A boolean that can be toggled to give focus to the password text field. + /// This must be manually set back to `false` when the text field finishes editing. + @State private var isPasswordFocused = false + + // MARK: Public + + @ObservedObject var viewModel: AuthenticationLoginViewModel.Context + + var body: some View { + ScrollView { + VStack(spacing: 0) { + header + .padding(.top, OnboardingMetrics.topPaddingToNavigationBar) + .padding(.bottom, 36) + + serverInfo + .padding(.leading, 12) + + Rectangle() + .fill(theme.colors.quinaryContent) + .frame(height: 1) + .padding(.vertical, 21) + + if viewModel.viewState.showLoginForm { + loginForm + } + + if viewModel.viewState.showLoginForm && viewModel.viewState.showSSOButtons { + Text(VectorL10n.or) + .foregroundColor(theme.colors.secondaryContent) + .padding(.top, 16) + } + + if viewModel.viewState.showSSOButtons { + ssoButtons + .padding(.top, 16) + } + + } + .readableFrame() + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .background(theme.colors.background.ignoresSafeArea()) + .alert(item: $viewModel.alertInfo) { $0.alert } + .accentColor(theme.colors.accent) + } + + /// The header containing the icon, title and message. + var header: some View { + Text(VectorL10n.authenticationLoginTitle) + .font(theme.fonts.title2B) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + } + + /// The sever information section that includes a button to select a different server. + var serverInfo: some View { + AuthenticationServerInfoSection(address: viewModel.viewState.homeserverAddress, + description: viewModel.viewState.serverDescription) { + viewModel.send(viewAction: .selectServer) + } + } + + /// The form with text fields for username and password, along with a submit button. + var loginForm: some View { + VStack(spacing: 14) { + RoundedBorderTextField(placeHolder: VectorL10n.authenticationLoginUsername, + text: $viewModel.username, + isFirstResponder: false, + configuration: UIKitTextInputConfiguration(returnKeyType: .next, + autocapitalizationType: .none, + autocorrectionType: .no), + onEditingChanged: usernameEditingChanged) + .accessibilityIdentifier("usernameTextField") + + Spacer().frame(height: 20) + + RoundedBorderTextField(placeHolder: VectorL10n.authPasswordPlaceholder, + text: $viewModel.password, + isFirstResponder: isPasswordFocused, + configuration: UIKitTextInputConfiguration(returnKeyType: .done, + isSecureTextEntry: true), + onEditingChanged: passwordEditingChanged) + .accessibilityIdentifier("passwordTextField") + + Button { } label: { + Text(VectorL10n.authenticationLoginForgotPassword) + .font(theme.fonts.body) + } + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.bottom, 8) + + Button(action: submit) { + Text(VectorL10n.next) + } + .buttonStyle(PrimaryActionButtonStyle()) + .disabled(!viewModel.viewState.hasValidCredentials) + .accessibilityIdentifier("nextButton") + } + } + + /// A list of SSO buttons that can be used for login. + var ssoButtons: some View { + VStack(spacing: 16) { + ForEach(viewModel.viewState.ssoIdentityProviders) { provider in + AuthenticationSSOButton(provider: provider) { + viewModel.send(viewAction: .continueWithSSO(id: provider.id)) + } + .accessibilityIdentifier("ssoButton") + } + } + } + + /// Give focus to the password text field. + func usernameEditingChanged(isEditing: Bool) { + guard !isEditing, !viewModel.username.isEmpty else { return } + + viewModel.send(viewAction: .parseUsername) + isPasswordFocused = true + } + + /// Submits the form if valid credentials have been input. + func passwordEditingChanged(isEditing: Bool) { + guard !isEditing else { return } + isPasswordFocused = false + submit() + } + + /// Sends the `next` view action so long as valid credentials have been input. + func submit() { + guard viewModel.viewState.hasValidCredentials else { return } + viewModel.send(viewAction: .next) + } +} + +// MARK: - Previews + +@available(iOS 15.0, *) +struct AuthenticationLogin_Previews: PreviewProvider { + static let stateRenderer = MockAuthenticationLoginScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .navigationViewStyle(.stack) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift index da72ac4eb..c86f20f32 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift @@ -58,7 +58,7 @@ struct AuthenticationRegistrationViewState: BindableState { /// A description that can be shown for the currently selected homeserver. var serverDescription: String? { guard homeserverAddress == "matrix.org" else { return nil } - return VectorL10n.authenticationRegistrationMatrixDescription + return VectorL10n.authenticationServerInfoMatrixDescription } /// Whether to show any SSO buttons. diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift index 1cbfffdf5..3dfe52ac3 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift @@ -53,9 +53,9 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { private var waitingIndicator: UserIndicator? /// The authentication service used for the registration. - var authenticationService: AuthenticationService { parameters.authenticationService } + private var authenticationService: AuthenticationService { parameters.authenticationService } /// The wizard used to handle the registration flow. May be `nil` when only SSO is supported. - var registrationWizard: RegistrationWizard? + private var registrationWizard: RegistrationWizard? { parameters.authenticationService.registrationWizard } // MARK: Public @@ -67,7 +67,6 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { @MainActor init(parameters: AuthenticationRegistrationCoordinatorParameters) { self.parameters = parameters - self.registrationWizard = parameters.authenticationService.registrationWizard let homeserver = parameters.authenticationService.state.homeserver let viewModel = AuthenticationRegistrationViewModel(homeserverAddress: homeserver.addressFromUser ?? homeserver.address, @@ -112,8 +111,8 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { } /// Show a blocking activity indicator whilst saving. - @MainActor private func startLoading(label: String? = nil) { - waitingIndicator = indicatorPresenter.present(.loading(label: label ?? VectorL10n.loading, isInteractionBlocking: true)) + @MainActor private func startLoading() { + waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) } /// Hide the currently displayed activity indicator. @@ -149,14 +148,13 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { return } - // reAuthHelper.data = state.password - let deviceDisplayName = UIDevice.current.isPhone ? VectorL10n.loginMobileDevice : VectorL10n.loginTabletDevice - startLoading() currentTask = Task { [weak self] in do { - let result = try await registrationWizard.createAccount(username: username, password: password, initialDeviceDisplayName: deviceDisplayName) + let result = try await registrationWizard.createAccount(username: username, + password: password, + initialDeviceDisplayName: UIDevice.current.initialDisplayName) guard !Task.isCancelled else { return } callback?(.completed(result)) @@ -230,8 +228,6 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { authenticationRegistrationViewModel.update(homeserverAddress: homeserver.addressFromUser ?? homeserver.address, showRegistrationForm: homeserver.registrationFlow != nil, ssoIdentityProviders: homeserver.preferredLoginMode.ssoIdentityProviders ?? []) - - self.registrationWizard = authenticationService.registrationWizard } navigationRouter.dismissModule(animated: true) { [weak self] in diff --git a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift index bd645f003..b0c137ceb 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift @@ -90,35 +90,9 @@ struct AuthenticationRegistrationScreen: View { /// The sever information section that includes a button to select a different server. var serverInfo: some View { - VStack(alignment: .leading, spacing: 4) { - Text(VectorL10n.authenticationRegistrationServerTitle) - .font(theme.fonts.subheadline) - .foregroundColor(theme.colors.secondaryContent) - - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(viewModel.viewState.homeserverAddress) - .font(theme.fonts.body) - .foregroundColor(theme.colors.primaryContent) - - if let serverDescription = viewModel.viewState.serverDescription { - Text(serverDescription) - .font(theme.fonts.caption1) - .foregroundColor(theme.colors.tertiaryContent) - .accessibilityIdentifier("serverDescriptionText") - } - } - - Spacer() - - Button { viewModel.send(viewAction: .selectServer) } label: { - Text(VectorL10n.edit) - .font(theme.fonts.body) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .overlay(RoundedRectangle(cornerRadius: 8).stroke(theme.colors.accent)) - } - } + AuthenticationServerInfoSection(address: viewModel.viewState.homeserverAddress, + description: viewModel.viewState.serverDescription) { + viewModel.send(viewAction: .selectServer) } } diff --git a/RiotSwiftUI/Modules/Onboarding/SplashScreen/Coordinator/OnboardingSplashScreenCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/SplashScreen/Coordinator/OnboardingSplashScreenCoordinator.swift index e269e55e2..534bb4e64 100644 --- a/RiotSwiftUI/Modules/Onboarding/SplashScreen/Coordinator/OnboardingSplashScreenCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/SplashScreen/Coordinator/OnboardingSplashScreenCoordinator.swift @@ -15,6 +15,7 @@ // import SwiftUI +import CommonKit protocol OnboardingSplashScreenCoordinatorProtocol: Coordinator, Presentable { var completion: ((OnboardingSplashScreenViewModelResult) -> Void)? { get set } @@ -29,6 +30,9 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator private let onboardingSplashScreenHostingController: VectorHostingController private var onboardingSplashScreenViewModel: OnboardingSplashScreenViewModelProtocol + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + // MARK: Public // Must be used only internally @@ -43,6 +47,8 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator onboardingSplashScreenViewModel = viewModel onboardingSplashScreenHostingController = VectorHostingController(rootView: view) onboardingSplashScreenHostingController.vc_removeBackTitle() + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingSplashScreenHostingController) } // MARK: - Public @@ -52,13 +58,33 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator MXLog.debug("[OnboardingSplashScreenCoordinator] OnboardingSplashScreenViewModel did complete with result: \(result).") guard let self = self else { return } switch result { - case .login, .register: + case .login: + self.startLoading() + self.completion?(result) + case .register: self.completion?(result) } } } func toPresentable() -> UIViewController { - return self.onboardingSplashScreenHostingController + return onboardingSplashScreenHostingController + } + + /// Stops any ongoing activities in the coordinator. + func stop() { + stopLoading() + } + + // MARK: - Private + + /// Show an activity indicator whilst loading. + private func startLoading() { + loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + private func stopLoading() { + loadingIndicator = nil } }