mirror of
https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios.git
synced 2026-04-29 04:36:58 +02:00
Add login screen.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
+231
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
+48
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+7
-11
@@ -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
|
||||
|
||||
+3
-29
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+28
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user