mirror of
https://github.com/vector-im/element-ios.git
synced 2024-09-28 23:32:41 +00:00
Add login screen.
This commit is contained in:
parent
b69772edf4
commit
0f12447748
19 changed files with 943 additions and 70 deletions
|
@ -23,13 +23,17 @@
|
||||||
// MARK: Onboarding Authentication WIP
|
// MARK: Onboarding Authentication WIP
|
||||||
"authentication_registration_title" = "Create your account";
|
"authentication_registration_title" = "Create your account";
|
||||||
"authentication_registration_message" = "We’ll need some info to get you set up.";
|
"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_username" = "Username";
|
||||||
"authentication_registration_password" = "Password";
|
|
||||||
"authentication_registration_username_footer" = "You can’t change this later";
|
"authentication_registration_username_footer" = "You can’t change this later";
|
||||||
"authentication_registration_password_footer" = "Must be 8 characters or more";
|
"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_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_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";
|
"authentication_server_selection_server_url" = "Server URL";
|
||||||
|
|
|
@ -34,4 +34,8 @@ import UIKit
|
||||||
return userInterfaceIdiom == .phone
|
return userInterfaceIdiom == .phone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var initialDisplayName: String {
|
||||||
|
isPhone ? VectorL10n.loginMobileDevice : VectorL10n.loginTabletDevice
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,30 +14,30 @@ public extension VectorL10n {
|
||||||
static var authenticationCancelFlowConfirmationMessage: String {
|
static var authenticationCancelFlowConfirmationMessage: String {
|
||||||
return VectorL10n.tr("Untranslated", "authentication_cancel_flow_confirmation_message")
|
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
|
/// This server would like to make sure you are not a robot
|
||||||
static var authenticationRecaptchaMessage: String {
|
static var authenticationRecaptchaMessage: String {
|
||||||
return VectorL10n.tr("Untranslated", "authentication_recaptcha_message")
|
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.
|
/// We’ll need some info to get you set up.
|
||||||
static var authenticationRegistrationMessage: String {
|
static var authenticationRegistrationMessage: String {
|
||||||
return VectorL10n.tr("Untranslated", "authentication_registration_message")
|
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
|
/// Must be 8 characters or more
|
||||||
static var authenticationRegistrationPasswordFooter: String {
|
static var authenticationRegistrationPasswordFooter: String {
|
||||||
return VectorL10n.tr("Untranslated", "authentication_registration_password_footer")
|
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
|
/// Create your account
|
||||||
static var authenticationRegistrationTitle: String {
|
static var authenticationRegistrationTitle: String {
|
||||||
return VectorL10n.tr("Untranslated", "authentication_registration_title")
|
return VectorL10n.tr("Untranslated", "authentication_registration_title")
|
||||||
|
@ -50,6 +50,14 @@ public extension VectorL10n {
|
||||||
static var authenticationRegistrationUsernameFooter: String {
|
static var authenticationRegistrationUsernameFooter: String {
|
||||||
return VectorL10n.tr("Untranslated", "authentication_registration_username_footer")
|
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.
|
/// Cannot find a server at this URL, please check it is correct.
|
||||||
static var authenticationServerSelectionGenericError: String {
|
static var authenticationServerSelectionGenericError: String {
|
||||||
return VectorL10n.tr("Untranslated", "authentication_server_selection_generic_error")
|
return VectorL10n.tr("Untranslated", "authentication_server_selection_generic_error")
|
||||||
|
|
|
@ -144,6 +144,43 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
|
||||||
callback?(.cancel(.register))
|
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
|
// MARK: - Registration
|
||||||
|
|
||||||
/// Pushes the server selection screen into the flow (other screens may also present it modally later).
|
/// 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
|
// MARK: - Registration Handlers
|
||||||
/// Determines the next screen to show from the flow result and pushes it.
|
/// Determines the next screen to show from the flow result and pushes it.
|
||||||
@MainActor private func handleRegistrationResult(_ result: RegistrationResult) {
|
@MainActor private func handleRegistrationResult(_ result: RegistrationResult) {
|
||||||
|
@ -378,7 +409,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
|
||||||
verificationListener.start()
|
verificationListener.start()
|
||||||
self.verificationListener = verificationListener
|
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))
|
callback?(.didLogin(session: session, authenticationFlow: flow, authenticationType: .other))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -397,7 +428,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc
|
||||||
/// Present the key verification screen modally.
|
/// Present the key verification screen modally.
|
||||||
private func presentCompleteSecurity() {
|
private func presentCompleteSecurity() {
|
||||||
guard let session = session else {
|
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()
|
authenticationDidComplete()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -427,7 +458,7 @@ extension AuthenticationCoordinator: KeyVerificationCoordinatorDelegate {
|
||||||
func keyVerificationCoordinatorDidComplete(_ coordinator: KeyVerificationCoordinatorType, otherUserId: String, otherDeviceId: String) {
|
func keyVerificationCoordinatorDidComplete(_ coordinator: KeyVerificationCoordinatorType, otherUserId: String, otherDeviceId: String) {
|
||||||
if let crypto = session?.crypto,
|
if let crypto = session?.crypto,
|
||||||
!crypto.backup.hasPrivateKeyInCryptoStore || !crypto.backup.enabled {
|
!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)
|
crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -163,7 +163,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
|
||||||
case .register:
|
case .register:
|
||||||
showUseCaseSelectionScreen()
|
showUseCaseSelectionScreen()
|
||||||
case .login:
|
case .login:
|
||||||
showLegacyAuthenticationScreen()
|
beginAuthentication(with: .login, onStart: coordinator.stop)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -232,6 +232,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
|
||||||
self.cancelAuthentication(flow: flow)
|
self.cancelAuthentication(flow: flow)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
authenticationCoordinator = coordinator
|
||||||
|
|
||||||
add(childCoordinator: coordinator)
|
add(childCoordinator: coordinator)
|
||||||
coordinator.start()
|
coordinator.start()
|
||||||
|
@ -256,7 +257,6 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
|
||||||
// These results are only sent by the new flow.
|
// These results are only sent by the new flow.
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Due to needing to preload the authVC, this breaks the Coordinator init/start pattern.
|
// 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 {
|
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()
|
authenticationCoordinator.presentPendingScreensIfNecessary()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<AuthenticationLoginErrorType>?
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
|
@ -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<AuthenticationLoginViewState,
|
||||||
|
Never,
|
||||||
|
AuthenticationLoginViewAction>
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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<Void, Error>? {
|
||||||
|
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 ?? [])
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -58,7 +58,7 @@ struct AuthenticationRegistrationViewState: BindableState {
|
||||||
/// A description that can be shown for the currently selected homeserver.
|
/// A description that can be shown for the currently selected homeserver.
|
||||||
var serverDescription: String? {
|
var serverDescription: String? {
|
||||||
guard homeserverAddress == "matrix.org" else { return nil }
|
guard homeserverAddress == "matrix.org" else { return nil }
|
||||||
return VectorL10n.authenticationRegistrationMatrixDescription
|
return VectorL10n.authenticationServerInfoMatrixDescription
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether to show any SSO buttons.
|
/// Whether to show any SSO buttons.
|
||||||
|
|
|
@ -53,9 +53,9 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
|
||||||
private var waitingIndicator: UserIndicator?
|
private var waitingIndicator: UserIndicator?
|
||||||
|
|
||||||
/// The authentication service used for the registration.
|
/// 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.
|
/// 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
|
// MARK: Public
|
||||||
|
|
||||||
|
@ -67,7 +67,6 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
|
||||||
|
|
||||||
@MainActor init(parameters: AuthenticationRegistrationCoordinatorParameters) {
|
@MainActor init(parameters: AuthenticationRegistrationCoordinatorParameters) {
|
||||||
self.parameters = parameters
|
self.parameters = parameters
|
||||||
self.registrationWizard = parameters.authenticationService.registrationWizard
|
|
||||||
|
|
||||||
let homeserver = parameters.authenticationService.state.homeserver
|
let homeserver = parameters.authenticationService.state.homeserver
|
||||||
let viewModel = AuthenticationRegistrationViewModel(homeserverAddress: homeserver.addressFromUser ?? homeserver.address,
|
let viewModel = AuthenticationRegistrationViewModel(homeserverAddress: homeserver.addressFromUser ?? homeserver.address,
|
||||||
|
@ -112,8 +111,8 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show a blocking activity indicator whilst saving.
|
/// Show a blocking activity indicator whilst saving.
|
||||||
@MainActor private func startLoading(label: String? = nil) {
|
@MainActor private func startLoading() {
|
||||||
waitingIndicator = indicatorPresenter.present(.loading(label: label ?? VectorL10n.loading, isInteractionBlocking: true))
|
waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hide the currently displayed activity indicator.
|
/// Hide the currently displayed activity indicator.
|
||||||
|
@ -149,14 +148,13 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// reAuthHelper.data = state.password
|
|
||||||
let deviceDisplayName = UIDevice.current.isPhone ? VectorL10n.loginMobileDevice : VectorL10n.loginTabletDevice
|
|
||||||
|
|
||||||
startLoading()
|
startLoading()
|
||||||
|
|
||||||
currentTask = Task { [weak self] in
|
currentTask = Task { [weak self] in
|
||||||
do {
|
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 }
|
guard !Task.isCancelled else { return }
|
||||||
callback?(.completed(result))
|
callback?(.completed(result))
|
||||||
|
@ -230,8 +228,6 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable {
|
||||||
authenticationRegistrationViewModel.update(homeserverAddress: homeserver.addressFromUser ?? homeserver.address,
|
authenticationRegistrationViewModel.update(homeserverAddress: homeserver.addressFromUser ?? homeserver.address,
|
||||||
showRegistrationForm: homeserver.registrationFlow != nil,
|
showRegistrationForm: homeserver.registrationFlow != nil,
|
||||||
ssoIdentityProviders: homeserver.preferredLoginMode.ssoIdentityProviders ?? [])
|
ssoIdentityProviders: homeserver.preferredLoginMode.ssoIdentityProviders ?? [])
|
||||||
|
|
||||||
self.registrationWizard = authenticationService.registrationWizard
|
|
||||||
}
|
}
|
||||||
|
|
||||||
navigationRouter.dismissModule(animated: true) { [weak self] in
|
navigationRouter.dismissModule(animated: true) { [weak self] in
|
||||||
|
|
|
@ -90,35 +90,9 @@ struct AuthenticationRegistrationScreen: View {
|
||||||
|
|
||||||
/// The sever information section that includes a button to select a different server.
|
/// The sever information section that includes a button to select a different server.
|
||||||
var serverInfo: some View {
|
var serverInfo: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
AuthenticationServerInfoSection(address: viewModel.viewState.homeserverAddress,
|
||||||
Text(VectorL10n.authenticationRegistrationServerTitle)
|
description: viewModel.viewState.serverDescription) {
|
||||||
.font(theme.fonts.subheadline)
|
viewModel.send(viewAction: .selectServer)
|
||||||
.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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import CommonKit
|
||||||
|
|
||||||
protocol OnboardingSplashScreenCoordinatorProtocol: Coordinator, Presentable {
|
protocol OnboardingSplashScreenCoordinatorProtocol: Coordinator, Presentable {
|
||||||
var completion: ((OnboardingSplashScreenViewModelResult) -> Void)? { get set }
|
var completion: ((OnboardingSplashScreenViewModelResult) -> Void)? { get set }
|
||||||
|
@ -29,6 +30,9 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator
|
||||||
private let onboardingSplashScreenHostingController: VectorHostingController
|
private let onboardingSplashScreenHostingController: VectorHostingController
|
||||||
private var onboardingSplashScreenViewModel: OnboardingSplashScreenViewModelProtocol
|
private var onboardingSplashScreenViewModel: OnboardingSplashScreenViewModelProtocol
|
||||||
|
|
||||||
|
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||||
|
private var loadingIndicator: UserIndicator?
|
||||||
|
|
||||||
// MARK: Public
|
// MARK: Public
|
||||||
|
|
||||||
// Must be used only internally
|
// Must be used only internally
|
||||||
|
@ -43,6 +47,8 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator
|
||||||
onboardingSplashScreenViewModel = viewModel
|
onboardingSplashScreenViewModel = viewModel
|
||||||
onboardingSplashScreenHostingController = VectorHostingController(rootView: view)
|
onboardingSplashScreenHostingController = VectorHostingController(rootView: view)
|
||||||
onboardingSplashScreenHostingController.vc_removeBackTitle()
|
onboardingSplashScreenHostingController.vc_removeBackTitle()
|
||||||
|
|
||||||
|
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingSplashScreenHostingController)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public
|
// MARK: - Public
|
||||||
|
@ -52,13 +58,33 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator
|
||||||
MXLog.debug("[OnboardingSplashScreenCoordinator] OnboardingSplashScreenViewModel did complete with result: \(result).")
|
MXLog.debug("[OnboardingSplashScreenCoordinator] OnboardingSplashScreenViewModel did complete with result: \(result).")
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
switch result {
|
switch result {
|
||||||
case .login, .register:
|
case .login:
|
||||||
|
self.startLoading()
|
||||||
|
self.completion?(result)
|
||||||
|
case .register:
|
||||||
self.completion?(result)
|
self.completion?(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func toPresentable() -> UIViewController {
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue