mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 00:49:44 +00:00
Add an AccountProvider type and use it within the authentication flow. (#6103)
* Add an AccountProvider type and use it within AppSettings. * Use the AccountProvider type within the Authentication flow too. * Fix unit test compilation with Swift 6.4 * Make sure defaultAccountProvider takes allowOtherAccountProviders into account. * Document a caveat on using AuthenticationService.homeserver.value.accountProvider.
This commit is contained in:
@@ -10,35 +10,10 @@ 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
|
||||
/// The account provider who operates the homeserver.
|
||||
let accountProvider: AccountProvider
|
||||
/// The types login supported by the homeserver.
|
||||
var loginMode: LoginMode
|
||||
|
||||
/// Creates a new homeserver value.
|
||||
init(address: String, loginMode: LoginMode) {
|
||||
let address = Self.sanitized(address).components(separatedBy: "://").last ?? address
|
||||
|
||||
self.address = address
|
||||
self.loginMode = loginMode
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -46,21 +21,21 @@ struct LoginHomeserver: Equatable {
|
||||
extension LoginHomeserver {
|
||||
/// A mock homeserver that is configured just like matrix.org.
|
||||
static var mockMatrixDotOrg: LoginHomeserver {
|
||||
LoginHomeserver(address: "matrix.org", loginMode: .oAuth(supportsCreatePrompt: true))
|
||||
LoginHomeserver(accountProvider: .generic("matrix.org"), loginMode: .oAuth(supportsCreatePrompt: true))
|
||||
}
|
||||
|
||||
/// A mock homeserver that supports login and registration via a password but has no OAuth support.
|
||||
static var mockBasicServer: LoginHomeserver {
|
||||
LoginHomeserver(address: "example.com", loginMode: .password)
|
||||
LoginHomeserver(accountProvider: .generic("example.com"), loginMode: .password)
|
||||
}
|
||||
|
||||
/// A mock homeserver that supports only supports authentication via OAuth.
|
||||
static var mockOAuth: LoginHomeserver {
|
||||
LoginHomeserver(address: "company.com", loginMode: .oAuth(supportsCreatePrompt: false))
|
||||
LoginHomeserver(accountProvider: .generic("company.com"), loginMode: .oAuth(supportsCreatePrompt: false))
|
||||
}
|
||||
|
||||
/// A mock homeserver that only with no supported login flows.
|
||||
static var mockUnsupported: LoginHomeserver {
|
||||
LoginHomeserver(address: "server.net", loginMode: .unsupported)
|
||||
LoginHomeserver(accountProvider: .generic("server.net"), loginMode: .unsupported)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ struct LoginScreen: View {
|
||||
BigIcon(icon: \.lockSolid)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Text(L10n.screenLoginTitleWithHomeserver(context.viewState.homeserver.address))
|
||||
Text(L10n.screenLoginTitleWithHomeserver(context.viewState.homeserver.accountProvider.serverNameOrBaseURL))
|
||||
.font(.compound.headingMDBold)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
@@ -127,7 +127,7 @@ struct LoginScreen: View {
|
||||
struct LoginScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = makeViewModel()
|
||||
static let credentialsViewModel = makeViewModel(withCredentials: true)
|
||||
static let unconfiguredViewModel = makeViewModel(homeserverAddress: "somethingtofailconfiguration")
|
||||
static let unconfiguredViewModel = makeViewModel(serverNameOrBaseURL: "somethingtofailconfiguration")
|
||||
|
||||
static var previews: some View {
|
||||
ElementNavigationStack {
|
||||
@@ -148,10 +148,10 @@ struct LoginScreen_Previews: PreviewProvider, TestablePreview {
|
||||
.previewDisplayName("Unsupported")
|
||||
}
|
||||
|
||||
static func makeViewModel(homeserverAddress: String = "example.com", withCredentials: Bool = false) -> LoginScreenViewModel {
|
||||
static func makeViewModel(serverNameOrBaseURL: String = "example.com", withCredentials: Bool = false) -> LoginScreenViewModel {
|
||||
let authenticationService = AuthenticationService.mock
|
||||
|
||||
Task { await authenticationService.configure(for: homeserverAddress, flow: .login) }
|
||||
Task { await authenticationService.configure(for: serverNameOrBaseURL, flow: .login) }
|
||||
|
||||
let viewModel = LoginScreenViewModel(authenticationService: authenticationService,
|
||||
loginHint: nil,
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ final class ServerSelectionScreenCoordinator: CoordinatorProtocol {
|
||||
let mode: ServerSelectionScreenMode = if parameters.appSettings.allowOtherAccountProviders {
|
||||
.userInput
|
||||
} else {
|
||||
.picker(parameters.appSettings.accountProviders)
|
||||
.picker(parameters.appSettings.accountProviders.map(\.serverNameOrBaseURL))
|
||||
}
|
||||
|
||||
viewModel = ServerSelectionScreenViewModel(authenticationService: parameters.authenticationService,
|
||||
|
||||
+6
-6
@@ -88,18 +88,18 @@ struct ServerSelectionScreenViewState: BindableState {
|
||||
var hasValidationError: Bool {
|
||||
switch mode {
|
||||
case .userInput:
|
||||
bindings.homeserverAddress.isEmpty || isShowingFooterError
|
||||
bindings.serverNameOrBaseURL.isEmpty || isShowingFooterError
|
||||
case .picker(let options):
|
||||
!options.contains(bindings.homeserverAddress) || isShowingFooterError
|
||||
!options.contains(bindings.serverNameOrBaseURL) || isShowingFooterError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerSelectionScreenBindings {
|
||||
/// The homeserver address input or chosen by the user.
|
||||
var homeserverAddress: String
|
||||
/// The selection range in `homeserverAddress`
|
||||
var homeserverSelection: TextSelection?
|
||||
/// The homeserver input or chosen by the user.
|
||||
var serverNameOrBaseURL: String
|
||||
/// The selection range in `serverNameOrBaseURL`
|
||||
var serverNameOrBaseURLSelection: TextSelection?
|
||||
|
||||
/// Information describing the currently displayed alert.
|
||||
var alertInfo: AlertInfo<ServerSelectionScreenErrorType>?
|
||||
|
||||
+18
-19
@@ -41,13 +41,12 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.homeserverHistoryManager = homeserverHistoryManager
|
||||
|
||||
let homeserverAddress: String
|
||||
if case .picker(let providers) = mode {
|
||||
homeserverAddress = providers[0]
|
||||
let serverNameOrBaseURL = if case .picker(let providers) = mode {
|
||||
providers[0]
|
||||
} else {
|
||||
homeserverAddress = authenticationService.homeserver.value.address
|
||||
authenticationService.homeserver.value.accountProvider.serverNameOrBaseURL
|
||||
}
|
||||
let bindings = ServerSelectionScreenBindings(homeserverAddress: homeserverAddress)
|
||||
let bindings = ServerSelectionScreenBindings(serverNameOrBaseURL: serverNameOrBaseURL)
|
||||
super.init(initialViewState: ServerSelectionScreenViewState(mode: mode, authenticationFlow: authenticationFlow, bindings: bindings))
|
||||
|
||||
context.viewState.textFieldAdapter.keystrokePublisher
|
||||
@@ -80,40 +79,40 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server
|
||||
// MARK: - Private
|
||||
|
||||
private func pickServer() async {
|
||||
let accountProvider = state.bindings.homeserverAddress
|
||||
guard accountProvider.isEmpty == false else {
|
||||
let serverNameOrBaseURL = state.bindings.serverNameOrBaseURL
|
||||
guard serverNameOrBaseURL.isEmpty == false else {
|
||||
fatalError("It shouldn't be possible to confirm without a selection.")
|
||||
}
|
||||
|
||||
startLoading()
|
||||
|
||||
switch await authenticationService.configure(for: accountProvider, flow: authenticationFlow) {
|
||||
switch await authenticationService.configure(for: serverNameOrBaseURL, flow: authenticationFlow) {
|
||||
case .success:
|
||||
MXLog.info("Selected server: \(accountProvider)")
|
||||
MXLog.info("Selected server: \(serverNameOrBaseURL)")
|
||||
await fetchLoginURLIfNeededAndContinue()
|
||||
stopLoading()
|
||||
case .failure:
|
||||
MXLog.info("Invalid server: \(accountProvider)")
|
||||
MXLog.info("Invalid server: \(serverNameOrBaseURL)")
|
||||
stopLoading()
|
||||
// When the servers are hard-coded they should have a valid configuration, so show a generic error.
|
||||
state.bindings.alertInfo = AlertInfo(id: .unknownError)
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the login flow using the supplied homeserver address, or shows an error when this isn't possible.
|
||||
/// Updates the login flow using the supplied server name or base URL, or shows an error when this isn't possible.
|
||||
private func configureHomeserver() async {
|
||||
let userInput = state.bindings.homeserverAddress
|
||||
let userInput = state.bindings.serverNameOrBaseURL
|
||||
// People often enter their Matrix ID here, so use the server name from it when they do.
|
||||
let homeserverAddress = (try? serverNameFromUserId(userId: userInput)) ?? userInput
|
||||
let serverNameOrBaseURL = (try? serverNameFromUserId(userId: userInput)) ?? userInput
|
||||
startLoading()
|
||||
|
||||
switch await authenticationService.configure(for: homeserverAddress, flow: authenticationFlow) {
|
||||
switch await authenticationService.configure(for: serverNameOrBaseURL, flow: authenticationFlow) {
|
||||
case .success:
|
||||
MXLog.info("Selected homeserver: \(homeserverAddress)")
|
||||
MXLog.info("Selected homeserver: \(serverNameOrBaseURL)")
|
||||
await fetchLoginURLIfNeededAndContinue()
|
||||
stopLoading()
|
||||
case .failure(let error):
|
||||
MXLog.info("Invalid homeserver: \(homeserverAddress)")
|
||||
MXLog.info("Invalid homeserver: \(serverNameOrBaseURL)")
|
||||
stopLoading()
|
||||
handleError(error)
|
||||
}
|
||||
@@ -153,7 +152,7 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server
|
||||
/// Processes an error to either update the flow or display it to the user.
|
||||
private func handleError(_ error: AuthenticationServiceError) {
|
||||
switch error {
|
||||
case .invalidServer, .invalidHomeserverAddress:
|
||||
case .invalidServer, .invalidServerNameOrBaseURL:
|
||||
showFooterMessage(L10n.screenChangeServerErrorInvalidHomeserver)
|
||||
case .invalidWellKnown(let error):
|
||||
state.bindings.alertInfo = AlertInfo(id: .invalidWellKnownAlert(error),
|
||||
@@ -221,9 +220,9 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server
|
||||
debouncedAutocompleteTask = Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
guard Task.isCancelled == false else { return }
|
||||
context.homeserverAddress = newInput
|
||||
context.serverNameOrBaseURL = newInput
|
||||
context.viewState.textField?.text = newInput
|
||||
context.homeserverSelection = TextSelection(range: selectionRange)
|
||||
context.serverNameOrBaseURLSelection = TextSelection(range: selectionRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-10
@@ -67,7 +67,7 @@ struct ServerSelectionScreen: View {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
switch context.viewState.mode {
|
||||
case .userInput:
|
||||
TextField(L10n.screenChangeServerTextfieldPlaceholder, text: $context.homeserverAddress, selection: $context.homeserverSelection)
|
||||
TextField(L10n.screenChangeServerTextfieldPlaceholder, text: $context.serverNameOrBaseURL, selection: $context.serverNameOrBaseURLSelection)
|
||||
.textFieldStyle(.compound(labelText: Text(L10n.screenChangeServerTextfieldHeader),
|
||||
footerText: Text(context.viewState.footerMessage),
|
||||
state: context.viewState.isShowingFooterError ? .error : .default,
|
||||
@@ -78,13 +78,13 @@ struct ServerSelectionScreen: View {
|
||||
.introspect(.textField, on: .supportedVersions) {
|
||||
context.send(viewAction: .updateTextField($0))
|
||||
}
|
||||
.onChange(of: context.homeserverAddress) { context.send(viewAction: .clearFooterError) }
|
||||
.onChange(of: context.serverNameOrBaseURL) { context.send(viewAction: .clearFooterError) }
|
||||
.submitLabel(.done)
|
||||
.onSubmit(submit)
|
||||
case .picker(let providers):
|
||||
FakeInlinePicker(items: providers,
|
||||
icon: \.host,
|
||||
selection: $context.homeserverAddress)
|
||||
selection: $context.serverNameOrBaseURL)
|
||||
.accessibilityIdentifier(A11yIdentifiers.serverConfirmationScreen.serverPicker)
|
||||
}
|
||||
}
|
||||
@@ -140,10 +140,10 @@ private struct FakeInlinePicker: View {
|
||||
|
||||
@available(iOS 26.0, *)
|
||||
struct ServerSelection_Previews: PreviewProvider, TestablePreview {
|
||||
static let matrixViewModel = makeViewModel(mode: .userInput, homeserverAddress: "matrix.org")
|
||||
static let emptyViewModel = makeViewModel(mode: .userInput, homeserverAddress: "")
|
||||
static let invalidViewModel = makeViewModel(mode: .userInput, homeserverAddress: "thisisbad")
|
||||
static let pickerViewModel = makeViewModel(mode: .picker(["matrix.org", "foo.bar", "baz.me"]), homeserverAddress: "foo.bar")
|
||||
static let matrixViewModel = makeViewModel(mode: .userInput, serverNameOrBaseURL: "matrix.org")
|
||||
static let emptyViewModel = makeViewModel(mode: .userInput, serverNameOrBaseURL: "")
|
||||
static let invalidViewModel = makeViewModel(mode: .userInput, serverNameOrBaseURL: "thisisbad")
|
||||
static let pickerViewModel = makeViewModel(mode: .picker(["matrix.org", "foo.bar", "baz.me"]), serverNameOrBaseURL: "foo.bar")
|
||||
|
||||
static var previews: some View {
|
||||
ElementNavigationStack {
|
||||
@@ -168,7 +168,7 @@ struct ServerSelection_Previews: PreviewProvider, TestablePreview {
|
||||
.previewDisplayName("Picker")
|
||||
}
|
||||
|
||||
static func makeViewModel(mode: ServerSelectionScreenMode = .userInput, homeserverAddress: String) -> ServerSelectionScreenViewModel {
|
||||
static func makeViewModel(mode: ServerSelectionScreenMode = .userInput, serverNameOrBaseURL: String) -> ServerSelectionScreenViewModel {
|
||||
let authenticationService = AuthenticationService.mock
|
||||
|
||||
let appSettings = AppSettings.volatile()
|
||||
@@ -179,8 +179,8 @@ struct ServerSelection_Previews: PreviewProvider, TestablePreview {
|
||||
appSettings: appSettings,
|
||||
homeserverHistoryManager: HomeserverHistoryManager(appSettings: appSettings),
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
viewModel.context.homeserverAddress = homeserverAddress
|
||||
if case .userInput = mode, homeserverAddress == "thisisbad" {
|
||||
viewModel.context.serverNameOrBaseURL = serverNameOrBaseURL
|
||||
if case .userInput = mode, serverNameOrBaseURL == "thisisbad" {
|
||||
viewModel.context.send(viewAction: .confirm)
|
||||
}
|
||||
return viewModel
|
||||
|
||||
+3
-3
@@ -24,7 +24,7 @@ struct AuthenticationStartScreenViewState: BindableState {
|
||||
/// The presentation anchor used for OAuth authentication.
|
||||
var window: UIWindow?
|
||||
|
||||
let serverName: String?
|
||||
let serverNameOrBaseURL: String?
|
||||
let showCreateAccountButton: Bool
|
||||
let showQRCodeLoginButton: Bool
|
||||
|
||||
@@ -36,8 +36,8 @@ struct AuthenticationStartScreenViewState: BindableState {
|
||||
var bindings = AuthenticationStartScreenViewStateBindings()
|
||||
|
||||
var loginButtonTitle: String {
|
||||
if let serverName {
|
||||
L10n.screenOnboardingSignInTo(serverName)
|
||||
if let serverNameOrBaseURL {
|
||||
L10n.screenOnboardingSignInTo(serverNameOrBaseURL)
|
||||
} else if showQRCodeLoginButton {
|
||||
L10n.screenOnboardingSignInManually
|
||||
} else {
|
||||
|
||||
+15
-12
@@ -40,27 +40,30 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
|
||||
canReportProblem = isBugReportServiceEnabled
|
||||
|
||||
let isQRCodeScanningSupported = !ProcessInfo.processInfo.isiOSAppOnMac
|
||||
let classicAppAccountProvider = authenticationService.classicAppAccount?.serverName
|
||||
let isClassicAppAccountAllowed = classicAppAccountProvider.map { appSettings.accountProviders.contains($0) } ?? false
|
||||
let isClassicAppAccountAllowed = if let classicAppAccount = authenticationService.classicAppAccount {
|
||||
appSettings.accountProviders.contains(serverName: classicAppAccount.serverName, orBaseURL: classicAppAccount.homeserverURL)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
let initialViewState = if !appSettings.allowOtherAccountProviders {
|
||||
// We don't show the create account button when custom providers are disallowed.
|
||||
// The assumption here being that if you're running a custom app, your users will already be created.
|
||||
AuthenticationStartScreenViewState(serverName: appSettings.accountProviders.count == 1 ? appSettings.accountProviders[0] : nil,
|
||||
AuthenticationStartScreenViewState(serverNameOrBaseURL: appSettings.accountProviders.count == 1 ? appSettings.accountProviders[0].serverNameOrBaseURL : nil,
|
||||
showCreateAccountButton: false,
|
||||
showQRCodeLoginButton: isQRCodeScanningSupported,
|
||||
classicAppMode: isClassicAppAccountAllowed ? authenticationService.classicAppAccount.map { .welcomeBack($0) } : nil,
|
||||
hideBrandChrome: appSettings.hideBrandChrome)
|
||||
} else if let provisioningParameters {
|
||||
// We only show the "Sign in to …" button when using a provisioning link.
|
||||
AuthenticationStartScreenViewState(serverName: provisioningParameters.accountProvider,
|
||||
AuthenticationStartScreenViewState(serverNameOrBaseURL: provisioningParameters.accountProvider,
|
||||
showCreateAccountButton: false,
|
||||
showQRCodeLoginButton: false,
|
||||
classicAppMode: nil,
|
||||
hideBrandChrome: appSettings.hideBrandChrome)
|
||||
} else {
|
||||
// The default configuration.
|
||||
AuthenticationStartScreenViewState(serverName: nil,
|
||||
AuthenticationStartScreenViewState(serverNameOrBaseURL: nil,
|
||||
showCreateAccountButton: appSettings.showCreateAccountButton,
|
||||
showQRCodeLoginButton: isQRCodeScanningSupported,
|
||||
classicAppMode: authenticationService.classicAppAccount.map { .welcomeBack($0) },
|
||||
@@ -114,22 +117,22 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
|
||||
if classicAppAccount.state.availableSecrets == .requiresBackup {
|
||||
state.bindings.showClassicAppBackupInstructions = true
|
||||
} else {
|
||||
await configureAccountProvider(classicAppAccount.serverName,
|
||||
loginHint: "mxid:\(classicAppAccount.userID)",
|
||||
fallbackHomeserverURL: classicAppAccount.homeserverURL)
|
||||
await loginDirectly(using: classicAppAccount.serverName,
|
||||
loginHint: "mxid:\(classicAppAccount.userID)",
|
||||
fallbackHomeserverURL: classicAppAccount.homeserverURL)
|
||||
}
|
||||
} else if let serverName = state.serverName {
|
||||
await configureAccountProvider(serverName, loginHint: provisioningParameters?.loginHint)
|
||||
} else if let serverNameOrBaseURL = state.serverNameOrBaseURL {
|
||||
await loginDirectly(using: serverNameOrBaseURL, loginHint: provisioningParameters?.loginHint)
|
||||
} else {
|
||||
actionsSubject.send(.login) // No need to configure anything here, continue the flow.
|
||||
}
|
||||
}
|
||||
|
||||
private func configureAccountProvider(_ accountProvider: String, loginHint: String? = nil, fallbackHomeserverURL: URL? = nil) async {
|
||||
private func loginDirectly(using serverNameOrBaseURL: String, loginHint: String? = nil, fallbackHomeserverURL: URL? = nil) async {
|
||||
startLoading()
|
||||
defer { stopLoading() }
|
||||
|
||||
if case .failure = await authenticationService.configure(for: accountProvider, flow: .login) {
|
||||
if case .failure = await authenticationService.configure(for: serverNameOrBaseURL, flow: .login) {
|
||||
// Try the fallback URL before showing an error.
|
||||
if let fallbackHomeserverURL,
|
||||
case .success = await authenticationService.configure(for: fallbackHomeserverURL.absoluteString, flow: .login) {
|
||||
|
||||
Reference in New Issue
Block a user