mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
#40: Add the login screen from EI.
- Remove SSO and replace fallback with OIDC.
This commit is contained in:
@@ -55,8 +55,6 @@ class AuthenticationCoordinator: Coordinator {
|
||||
switch action {
|
||||
case .login:
|
||||
self.showLoginScreen()
|
||||
case .register:
|
||||
fatalError("Not implemented")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,8 +67,9 @@ class AuthenticationCoordinator: Coordinator {
|
||||
}
|
||||
|
||||
private func showLoginScreen() {
|
||||
let parameters = LoginScreenCoordinatorParameters()
|
||||
let coordinator = LoginScreenCoordinator(parameters: parameters)
|
||||
let homeserver = LoginHomeserver(address: BuildSettings.defaultHomeserverURLString)
|
||||
let parameters = LoginCoordinatorParameters(navigationRouter: navigationRouter, homeserver: homeserver)
|
||||
let coordinator = LoginCoordinator(parameters: parameters)
|
||||
|
||||
coordinator.callback = { [weak self, weak coordinator] action in
|
||||
guard let self = self, let coordinator = coordinator else {
|
||||
@@ -78,9 +77,9 @@ class AuthenticationCoordinator: Coordinator {
|
||||
}
|
||||
|
||||
switch action {
|
||||
case .login(let result):
|
||||
case .login(let username, let password):
|
||||
Task {
|
||||
switch await self.login(username: result.username, password: result.password) {
|
||||
switch await self.login(username: username, password: password) {
|
||||
case .success(let userSession):
|
||||
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
|
||||
self.remove(childCoordinator: coordinator)
|
||||
@@ -90,6 +89,8 @@ class AuthenticationCoordinator: Coordinator {
|
||||
MXLog.error("Failed logging in user with error: \(error)")
|
||||
}
|
||||
}
|
||||
case .continueWithOIDC:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//
|
||||
// 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 MatrixRustSDK
|
||||
|
||||
struct LoginCoordinatorParameters {
|
||||
let navigationRouter: NavigationRouterType
|
||||
/// The homeserver to be shown initially.
|
||||
let homeserver: LoginHomeserver
|
||||
}
|
||||
|
||||
enum LoginCoordinatorAction: CustomStringConvertible {
|
||||
/// Login with the associated username and password.
|
||||
case login(username: String, password: String)
|
||||
/// Continue using OIDC.
|
||||
case continueWithOIDC
|
||||
|
||||
/// A string representation of the action, ignoring any associated values that could leak PII.
|
||||
var description: String {
|
||||
switch self {
|
||||
case .login:
|
||||
return "login"
|
||||
case .continueWithOIDC:
|
||||
return "continueWithOIDC"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class LoginCoordinator: Coordinator, Presentable {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let parameters: LoginCoordinatorParameters
|
||||
private let loginHostingController: UIViewController
|
||||
private var loginViewModel: LoginViewModelProtocol
|
||||
|
||||
private var currentTask: Task<Void, Error>? {
|
||||
willSet {
|
||||
currentTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var activityIndicator: UserIndicator?
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: (@MainActor (LoginCoordinatorAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: LoginCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let viewModel = LoginViewModel(homeserver: parameters.homeserver)
|
||||
loginViewModel = viewModel
|
||||
|
||||
let view = LoginScreen(viewModel: viewModel.context)
|
||||
loginHostingController = UIHostingController(rootView: view)
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: loginHostingController)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
func start() {
|
||||
MXLog.debug("[LoginCoordinator] did start.")
|
||||
|
||||
loginViewModel.callback = { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
MXLog.debug("[LoginCoordinator] LoginViewModel did callback with result: \(action).")
|
||||
|
||||
switch action {
|
||||
case .selectServer:
|
||||
self.presentServerSelectionScreen()
|
||||
case .parseUsername(let username):
|
||||
self.parseUsername(username)
|
||||
case .forgotPassword:
|
||||
self.showForgotPasswordScreen()
|
||||
case .login(let username, let password):
|
||||
self.login(username: username, password: password)
|
||||
case .continueWithOIDC:
|
||||
self.callback?(.continueWithOIDC)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
loginHostingController
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Show a blocking activity indicator whilst saving.
|
||||
private func startLoading(isInteractionBlocking: Bool) {
|
||||
activityIndicator = indicatorPresenter.present(.loading(label: ElementL10n.loading, isInteractionBlocking: isInteractionBlocking))
|
||||
|
||||
if !isInteractionBlocking {
|
||||
loginViewModel.update(isLoading: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a non-blocking indicator that an operation was successful.
|
||||
private func indicateSuccess() {
|
||||
activityIndicator = indicatorPresenter.present(.success(label: ElementL10n.dialogTitleSuccess))
|
||||
}
|
||||
|
||||
/// Show a non-blocking indicator that an operation failed.
|
||||
private func indicateFailure() {
|
||||
activityIndicator = indicatorPresenter.present(.error(label: ElementL10n.dialogTitleError))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
loginViewModel.update(isLoading: false)
|
||||
activityIndicator = nil
|
||||
}
|
||||
|
||||
/// Processes an error to either update the flow or display it to the user.
|
||||
private func handleError(_ error: Error) {
|
||||
loginViewModel.displayError(.alert(error.localizedDescription))
|
||||
}
|
||||
|
||||
/// Requests the authentication coordinator to log in using the specified credentials.
|
||||
private func login(username: String, password: String) {
|
||||
var username = loginViewModel.context.username
|
||||
|
||||
if !isMXID(username: username) {
|
||||
let homeserver = loginViewModel.context.viewState.homeserver
|
||||
username = "@\(username):\(homeserver.address)"
|
||||
}
|
||||
|
||||
callback?(.login(username: username, password: password))
|
||||
}
|
||||
|
||||
/// Parses the specified username and looks up the homeserver when a Matrix ID is entered.
|
||||
private func parseUsername(_ username: String) {
|
||||
guard isMXID(username: username) else { return }
|
||||
|
||||
let domain = String(username.split(separator: ":")[1])
|
||||
|
||||
let homeserver = LoginHomeserver(address: domain)
|
||||
updateViewModel(homeserver: homeserver)
|
||||
indicateSuccess()
|
||||
}
|
||||
|
||||
/// Checks whether the specified username is a Matrix ID or not.
|
||||
private func isMXID(username: String) -> Bool {
|
||||
let range = NSRange(location: 0, length: username.count)
|
||||
|
||||
let detector = try? NSRegularExpression(pattern: MatrixEntityRegex.userId.rawValue, options: .caseInsensitive)
|
||||
return detector?.numberOfMatches(in: username, range: range) ?? 0 > 0
|
||||
}
|
||||
|
||||
/// Updates the view model with a different homeserver.
|
||||
private func updateViewModel(homeserver: LoginHomeserver) {
|
||||
loginViewModel.update(homeserver: homeserver)
|
||||
indicateSuccess()
|
||||
}
|
||||
|
||||
/// Presents the server selection screen as a modal.
|
||||
private func presentServerSelectionScreen() {
|
||||
loginViewModel.displayError(.alert("Not implemented. Enter a full Matrix ID such as @user:server.com"))
|
||||
}
|
||||
|
||||
/// Shows the forgot password screen.
|
||||
private func showForgotPasswordScreen() {
|
||||
loginViewModel.displayError(.alert("Not implemented."))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
/// Information about a homeserver that is ready for display in the authentication flow.
|
||||
struct LoginHomeserver: Equatable {
|
||||
/// The homeserver string to be shown to the user.
|
||||
let address: String
|
||||
/// Whether or not the homeserver is matrix.org.
|
||||
let isMatrixDotOrg: Bool
|
||||
/// The types login supported by the homeserver.
|
||||
let loginMode: LoginMode
|
||||
}
|
||||
|
||||
extension LoginHomeserver {
|
||||
/// Temporary initialiser for use until the FFI has homeserver discovery etc.
|
||||
init(address: String) {
|
||||
let address = Self.sanitized(address).components(separatedBy: "://").last ?? address
|
||||
|
||||
self.address = address
|
||||
isMatrixDotOrg = address == "matrix.org"
|
||||
loginMode = .password
|
||||
}
|
||||
|
||||
/// Sanitizes a user entered homeserver address with the following rules
|
||||
/// - Trim any whitespace.
|
||||
/// - Lowercase the address.
|
||||
/// - Ensure the address contains a scheme, otherwise make it `https`.
|
||||
/// - Remove any trailing slashes.
|
||||
static func sanitized(_ address: String) -> String {
|
||||
var address = address.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
|
||||
if !address.contains("://") {
|
||||
address = "https://\(address)"
|
||||
}
|
||||
|
||||
address = address.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
|
||||
return address
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mocks
|
||||
|
||||
extension LoginHomeserver {
|
||||
/// A mock homeserver that is configured just like matrix.org.
|
||||
static var mockMatrixDotOrg: LoginHomeserver {
|
||||
LoginHomeserver(address: "matrix.org",
|
||||
isMatrixDotOrg: true,
|
||||
loginMode: .password)
|
||||
}
|
||||
|
||||
/// A mock homeserver that supports login and registration via a password but has no SSO providers.
|
||||
static var mockBasicServer: LoginHomeserver {
|
||||
LoginHomeserver(address: "example.com",
|
||||
isMatrixDotOrg: false,
|
||||
loginMode: .password)
|
||||
}
|
||||
|
||||
/// A mock homeserver that supports only supports authentication via a single SSO provider.
|
||||
static var mockOIDC: LoginHomeserver {
|
||||
LoginHomeserver(address: "company.com",
|
||||
isMatrixDotOrg: false,
|
||||
// swiftlint:disable:next force_unwrapping
|
||||
loginMode: .oidc(URL(string: "https://auth.company.com")!))
|
||||
}
|
||||
|
||||
/// A mock homeserver that only with no supported login flows.
|
||||
static var mockUnsupported: LoginHomeserver {
|
||||
LoginHomeserver(address: "server.net",
|
||||
isMatrixDotOrg: false,
|
||||
loginMode: .unsupported)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
/// The supported forms of login that a homeserver allows.
|
||||
enum LoginMode: Equatable {
|
||||
/// The login mode hasn't been determined yet.
|
||||
case unknown
|
||||
/// The homeserver supports login via OpenID Connect at the associated URL.
|
||||
case oidc(URL)
|
||||
/// The homeserver supports login with a password.
|
||||
case password
|
||||
/// The homeserver only allows login with unsupported mechanisms. Use fallback instead.
|
||||
case unsupported
|
||||
|
||||
var supportsOIDCFlow: Bool {
|
||||
switch self {
|
||||
case .oidc:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var supportsPasswordFlow: Bool {
|
||||
switch self {
|
||||
case .password:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isUnsupported: Bool {
|
||||
switch self {
|
||||
case .unsupported:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//
|
||||
// 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 LoginViewModelAction: CustomStringConvertible {
|
||||
/// 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)
|
||||
/// Continue using OIDC.
|
||||
case continueWithOIDC
|
||||
|
||||
/// A string representation of the action, ignoring any associated values that could leak PII.
|
||||
var description: String {
|
||||
switch self {
|
||||
case .selectServer:
|
||||
return "selectServer"
|
||||
case .parseUsername:
|
||||
return "parseUsername"
|
||||
case .forgotPassword:
|
||||
return "forgotPassword"
|
||||
case .login:
|
||||
return "login"
|
||||
case .continueWithOIDC:
|
||||
return "continueWithOIDC"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: View
|
||||
|
||||
struct LoginViewState: BindableState {
|
||||
/// Data about the selected homeserver.
|
||||
var homeserver: LoginHomeserver
|
||||
/// Whether a new homeserver is currently being loaded.
|
||||
var isLoading: Bool = false
|
||||
/// View state that can be bound to from SwiftUI.
|
||||
var bindings: LoginBindings
|
||||
|
||||
/// The types of login supported by the homeserver.
|
||||
var loginMode: LoginMode { homeserver.loginMode }
|
||||
|
||||
/// `true` if the username and password are ready to be submitted.
|
||||
var hasValidCredentials: Bool {
|
||||
!bindings.username.isEmpty && !bindings.password.isEmpty
|
||||
}
|
||||
|
||||
/// `true` when valid credentials have been entered and a homeserver has been loaded.
|
||||
var canSubmit: Bool {
|
||||
hasValidCredentials && !isLoading
|
||||
}
|
||||
}
|
||||
|
||||
struct LoginBindings {
|
||||
/// 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<LoginErrorType>?
|
||||
}
|
||||
|
||||
enum LoginViewAction {
|
||||
/// 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
|
||||
/// Continue using OIDC.
|
||||
case continueWithOIDC
|
||||
}
|
||||
|
||||
enum LoginErrorType: Hashable {
|
||||
/// A specific error message shown in an alert.
|
||||
case alert(String)
|
||||
/// Looking up the homeserver from the username failed.
|
||||
case invalidHomeserver
|
||||
/// The response from the homeserver was unexpected.
|
||||
case unknown
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// 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 LoginViewModelType = StateStoreViewModel<LoginViewState, LoginViewAction>
|
||||
|
||||
class LoginViewModel: LoginViewModelType, LoginViewModelProtocol {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var callback: (@MainActor (LoginViewModelAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(homeserver: LoginHomeserver) {
|
||||
let bindings = LoginBindings()
|
||||
let viewState = LoginViewState(homeserver: homeserver, bindings: bindings)
|
||||
|
||||
super.init(initialViewState: viewState)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: LoginViewAction) async {
|
||||
switch viewAction {
|
||||
case .selectServer:
|
||||
callback?(.selectServer)
|
||||
case .parseUsername:
|
||||
callback?(.parseUsername(state.bindings.username))
|
||||
case .forgotPassword:
|
||||
callback?(.forgotPassword)
|
||||
case .next:
|
||||
callback?(.login(username: state.bindings.username, password: state.bindings.password))
|
||||
case .continueWithOIDC:
|
||||
callback?(.continueWithOIDC)
|
||||
}
|
||||
}
|
||||
|
||||
func update(isLoading: Bool) {
|
||||
guard state.isLoading != isLoading else { return }
|
||||
state.isLoading = isLoading
|
||||
}
|
||||
|
||||
func update(homeserver: LoginHomeserver) {
|
||||
state.homeserver = homeserver
|
||||
}
|
||||
|
||||
func displayError(_ type: LoginErrorType) {
|
||||
switch type {
|
||||
case .alert(let message):
|
||||
state.bindings.alertInfo = AlertInfo(id: type,
|
||||
title: ElementL10n.dialogTitleError,
|
||||
message: message)
|
||||
case .invalidHomeserver:
|
||||
state.bindings.alertInfo = AlertInfo(id: type,
|
||||
title: ElementL10n.dialogTitleError,
|
||||
message: ElementL10n.loginSigninMatrixIdErrorInvalidMatrixId)
|
||||
case .unknown:
|
||||
state.bindings.alertInfo = AlertInfo(id: type)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
@MainActor
|
||||
protocol LoginViewModelProtocol {
|
||||
|
||||
var callback: (@MainActor (LoginViewModelAction) -> Void)? { get set }
|
||||
var context: LoginViewModelType.Context { get }
|
||||
|
||||
/// Update the view to reflect that a new homeserver is being loaded.
|
||||
/// - Parameter isLoading: Whether or not the homeserver is being loaded.
|
||||
func update(isLoading: Bool)
|
||||
|
||||
/// Update the view with new homeserver information.
|
||||
/// - Parameter homeserver: The view data for the homeserver. This can be generated using `AuthenticationService.Homeserver.viewData`.
|
||||
func update(homeserver: LoginHomeserver)
|
||||
|
||||
/// Display an error to the user.
|
||||
/// - Parameter type: The type of error to be displayed.
|
||||
func displayError(_ type: LoginErrorType)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//
|
||||
// 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 LoginScreen: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
/// The focus state of the username text field.
|
||||
@FocusState private var isUsernameFocused: Bool
|
||||
/// The focus state of the password text field.
|
||||
@FocusState private var isPasswordFocused: Bool
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@ObservedObject var viewModel: LoginViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
.padding(.top, UIConstants.topPaddingToNavigationBar)
|
||||
.padding(.bottom, 36)
|
||||
|
||||
serverInfo
|
||||
.padding(.leading, 12)
|
||||
|
||||
Rectangle()
|
||||
.fill(Color.element.quinaryContent)
|
||||
.frame(height: 1)
|
||||
.padding(.vertical, 21)
|
||||
|
||||
switch viewModel.viewState.loginMode {
|
||||
case .password:
|
||||
loginForm
|
||||
case .oidc:
|
||||
oidcButton
|
||||
default:
|
||||
loginUnavailableText
|
||||
}
|
||||
|
||||
}
|
||||
.readableFrame()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.background(Color.element.background.ignoresSafeArea())
|
||||
.alert(item: $viewModel.alertInfo) { $0.alert }
|
||||
}
|
||||
|
||||
/// The header containing a Welcome Back title.
|
||||
var header: some View {
|
||||
Text(ElementL10n.authenticationLoginTitle)
|
||||
.font(.element.title2B)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
}
|
||||
|
||||
/// The sever information section that includes a button to select a different server.
|
||||
var serverInfo: some View {
|
||||
LoginServerInfoSection(address: viewModel.viewState.homeserver.address,
|
||||
showMatrixDotOrgInfo: viewModel.viewState.homeserver.isMatrixDotOrg) {
|
||||
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) {
|
||||
TextField(ElementL10n.loginSigninUsernameHint, text: $viewModel.username)
|
||||
.focused($isUsernameFocused)
|
||||
.textFieldStyle(.elementInput())
|
||||
.disableAutocorrection(true)
|
||||
.textContentType(.username)
|
||||
.autocapitalization(.none)
|
||||
.submitLabel(.next)
|
||||
.onChange(of: isUsernameFocused, perform: usernameFocusChanged)
|
||||
.onSubmit { isPasswordFocused = true }
|
||||
.accessibilityIdentifier("usernameTextField")
|
||||
|
||||
Spacer().frame(height: 20)
|
||||
|
||||
SecureField(ElementL10n.loginSignupPasswordHint, text: $viewModel.password)
|
||||
.focused($isPasswordFocused)
|
||||
.textFieldStyle(.elementInput())
|
||||
.textContentType(.password)
|
||||
.submitLabel(.done)
|
||||
.onSubmit(submit)
|
||||
.accessibilityIdentifier("passwordTextField")
|
||||
|
||||
Button { viewModel.send(viewAction: .forgotPassword) } label: {
|
||||
Text(ElementL10n.authenticationLoginForgotPassword)
|
||||
.font(.element.body)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Button(action: submit) {
|
||||
Text(ElementL10n.loginSignupSubmit)
|
||||
}
|
||||
.buttonStyle(.elementAction(.xLarge))
|
||||
.disabled(!viewModel.viewState.canSubmit)
|
||||
.accessibilityIdentifier("nextButton")
|
||||
}
|
||||
}
|
||||
|
||||
/// The OIDC button that can be used for login.
|
||||
var oidcButton: some View {
|
||||
Button { viewModel.send(viewAction: .continueWithOIDC) } label: {
|
||||
Text(ElementL10n.loginContinue)
|
||||
}
|
||||
.buttonStyle(.elementAction(.xLarge))
|
||||
.accessibilityIdentifier("oidcButton")
|
||||
}
|
||||
|
||||
/// Text shown if neither password or OIDC login is supported.
|
||||
var loginUnavailableText: some View {
|
||||
Text(ElementL10n.autodiscoverWellKnownError)
|
||||
.font(.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityIdentifier("unsupportedServerText")
|
||||
}
|
||||
|
||||
/// Parses the username for a homeserver.
|
||||
private func usernameFocusChanged(isFocussed: Bool) {
|
||||
guard !isFocussed, !viewModel.username.isEmpty else { return }
|
||||
viewModel.send(viewAction: .parseUsername)
|
||||
}
|
||||
|
||||
/// Sends the `next` view action so long as valid credentials have been input.
|
||||
private func submit() {
|
||||
guard viewModel.viewState.canSubmit else { return }
|
||||
viewModel.send(viewAction: .next)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct Login_Previews: PreviewProvider {
|
||||
static let credentialsViewModel: LoginViewModel = {
|
||||
let viewModel = LoginViewModel(homeserver: .mockMatrixDotOrg)
|
||||
viewModel.context.username = "alice"
|
||||
viewModel.context.password = "password"
|
||||
return viewModel
|
||||
}()
|
||||
|
||||
static var previews: some View {
|
||||
screen(for: LoginViewModel(homeserver: .mockMatrixDotOrg))
|
||||
screen(for: credentialsViewModel)
|
||||
screen(for: LoginViewModel(homeserver: .mockBasicServer))
|
||||
screen(for: LoginViewModel(homeserver: .mockOIDC))
|
||||
}
|
||||
|
||||
static func screen(for viewModel: LoginViewModel) -> some View {
|
||||
NavigationView {
|
||||
LoginScreen(viewModel: viewModel.context)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.tint(.element.accent)
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
/// A view that shows information about the chosen homeserver,
|
||||
/// along with an edit button to pick a different one.
|
||||
struct LoginServerInfoSection: View {
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
/// The address shown for the server.
|
||||
let address: String
|
||||
/// Whether or not to show the matrix.org description.
|
||||
let showMatrixDotOrgInfo: Bool
|
||||
/// The action performed when tapping the edit button.
|
||||
let editAction: () -> Void
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(ElementL10n.authenticationServerInfoTitle)
|
||||
.font(.element.subheadline)
|
||||
.foregroundColor(.element.secondaryContent)
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(address)
|
||||
.font(.element.body)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
|
||||
if showMatrixDotOrgInfo {
|
||||
Text(ElementL10n.authenticationServerInfoMatrixDescription)
|
||||
.font(.element.caption1)
|
||||
.foregroundColor(.element.tertiaryContent)
|
||||
.accessibilityIdentifier("serverDescriptionText")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: editAction) {
|
||||
Text(ElementL10n.edit)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.buttonStyle(.elementGhost())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user