mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
Add support for Account Provisioning links. (#4108)
* Add support for account provisioning links and route them to the authentication flow. * Use the provisioning parameters to configure the authentication flow. * Add UI tests for the provisioned authentication flow. * Record new preview snapshots. * Add unit tests. * Make the domain configurable in the app settings. * Use the loginHint in the login screen too.
This commit is contained in:
@@ -11,6 +11,8 @@ import SwiftUI
|
||||
struct LoginScreenCoordinatorParameters {
|
||||
/// The service used to authenticate the user.
|
||||
let authenticationService: AuthenticationServiceProtocol
|
||||
/// An optional hint that can be used to pre-fill the form.
|
||||
let loginHint: String?
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
let analytics: AnalyticsService
|
||||
}
|
||||
@@ -42,6 +44,7 @@ final class LoginScreenCoordinator: CoordinatorProtocol {
|
||||
self.parameters = parameters
|
||||
|
||||
viewModel = LoginScreenViewModel(authenticationService: parameters.authenticationService,
|
||||
loginHint: parameters.loginHint,
|
||||
userIndicatorController: parameters.userIndicatorController,
|
||||
analytics: parameters.analytics)
|
||||
}
|
||||
|
||||
@@ -21,13 +21,21 @@ class LoginScreenViewModel: LoginScreenViewModelType, LoginScreenViewModelProtoc
|
||||
}
|
||||
|
||||
init(authenticationService: AuthenticationServiceProtocol,
|
||||
loginHint: String?,
|
||||
userIndicatorController: UserIndicatorControllerProtocol,
|
||||
analytics: AnalyticsService) {
|
||||
self.authenticationService = authenticationService
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.analytics = analytics
|
||||
|
||||
let viewState = LoginScreenViewState(homeserver: authenticationService.homeserver.value)
|
||||
let username = switch loginHint {
|
||||
case .some(let hint) where hint.hasPrefix("mxid:"): String(hint.dropFirst(5)) // MSC4198
|
||||
case .some(let hint): hint
|
||||
case .none: ""
|
||||
}
|
||||
|
||||
let viewState = LoginScreenViewState(homeserver: authenticationService.homeserver.value,
|
||||
bindings: LoginScreenBindings(username: username))
|
||||
|
||||
super.init(initialViewState: viewState)
|
||||
|
||||
|
||||
@@ -161,6 +161,7 @@ struct LoginScreen_Previews: PreviewProvider, TestablePreview {
|
||||
Task { await authenticationService.configure(for: homeserverAddress, flow: .login) }
|
||||
|
||||
let viewModel = LoginScreenViewModel(authenticationService: authenticationService,
|
||||
loginHint: nil,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
analytics: ServiceLocator.shared.analytics)
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ class OIDCAuthenticationPresenter: NSObject {
|
||||
private let presentationAnchor: UIWindow
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
|
||||
private var activeSession: ASWebAuthenticationSession?
|
||||
|
||||
init(authenticationService: AuthenticationServiceProtocol,
|
||||
oidcRedirectURL: URL,
|
||||
presentationAnchor: UIWindow,
|
||||
@@ -36,9 +38,12 @@ class OIDCAuthenticationPresenter: NSObject {
|
||||
session.prefersEphemeralWebBrowserSession = false
|
||||
session.presentationContextProvider = self
|
||||
|
||||
activeSession = session
|
||||
session.start()
|
||||
}
|
||||
|
||||
activeSession = nil
|
||||
|
||||
guard let url else {
|
||||
// Check for user cancellation to avoid showing an alert in that instance.
|
||||
if let nsError = error as? NSError,
|
||||
@@ -76,6 +81,10 @@ class OIDCAuthenticationPresenter: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
activeSession?.cancel()
|
||||
}
|
||||
|
||||
private static let loadingIndicatorID = "\(OIDCAuthenticationPresenter.self)-Loading"
|
||||
|
||||
private func startLoading(delay: Duration? = nil) {
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ class ServerConfirmationScreenViewModel: ServerConfirmationScreenViewModelType,
|
||||
startLoading() // Uses the same ID, so no need to worry if the indicator already exists
|
||||
defer { stopLoading() }
|
||||
|
||||
switch await authenticationService.urlForOIDCLogin() {
|
||||
switch await authenticationService.urlForOIDCLogin(loginHint: nil) {
|
||||
case .success(let oidcData):
|
||||
actionsSubject.send(.continueWithOIDC(data: oidcData, window: window))
|
||||
case .failure:
|
||||
|
||||
+1
-1
@@ -134,7 +134,7 @@ final class SoftLogoutScreenCoordinator: CoordinatorProtocol {
|
||||
startLoading()
|
||||
|
||||
Task {
|
||||
switch await authenticationService.urlForOIDCLogin() {
|
||||
switch await authenticationService.urlForOIDCLogin(loginHint: nil) {
|
||||
case .failure(let error):
|
||||
stopLoading()
|
||||
handleError(error)
|
||||
|
||||
+16
-5
@@ -9,8 +9,11 @@ import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct AuthenticationStartScreenParameters {
|
||||
let showCreateAccountButton: Bool
|
||||
let authenticationService: AuthenticationServiceProtocol
|
||||
let provisioningParameters: AccountProvisioningParameters?
|
||||
let isBugReportServiceEnabled: Bool
|
||||
let appSettings: AppSettings
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
}
|
||||
|
||||
final class AuthenticationStartScreenCoordinator: CoordinatorProtocol {
|
||||
@@ -23,8 +26,11 @@ final class AuthenticationStartScreenCoordinator: CoordinatorProtocol {
|
||||
}
|
||||
|
||||
init(parameters: AuthenticationStartScreenParameters) {
|
||||
viewModel = AuthenticationStartScreenViewModel(showCreateAccountButton: parameters.showCreateAccountButton,
|
||||
isBugReportServiceEnabled: parameters.isBugReportServiceEnabled)
|
||||
viewModel = AuthenticationStartScreenViewModel(authenticationService: parameters.authenticationService,
|
||||
provisioningParameters: parameters.provisioningParameters,
|
||||
isBugReportServiceEnabled: parameters.isBugReportServiceEnabled,
|
||||
appSettings: parameters.appSettings,
|
||||
userIndicatorController: parameters.userIndicatorController)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
@@ -35,14 +41,19 @@ final class AuthenticationStartScreenCoordinator: CoordinatorProtocol {
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .loginManually:
|
||||
actionsSubject.send(.loginManually)
|
||||
case .loginWithQR:
|
||||
actionsSubject.send(.loginWithQR)
|
||||
case .login:
|
||||
actionsSubject.send(.login)
|
||||
case .register:
|
||||
actionsSubject.send(.register)
|
||||
case .reportProblem:
|
||||
actionsSubject.send(.reportProblem)
|
||||
|
||||
case .loginDirectlyWithOIDC(let data, let window):
|
||||
actionsSubject.send(.loginDirectlyWithOIDC(data: data, window: window))
|
||||
case .loginDirectlyWithPassword(let loginHint):
|
||||
actionsSubject.send(.loginDirectlyWithPassword(loginHint: loginHint))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
+39
-6
@@ -11,27 +11,60 @@ import SwiftUI
|
||||
|
||||
enum AuthenticationStartScreenCoordinatorAction {
|
||||
case loginWithQR
|
||||
case loginManually
|
||||
case login
|
||||
case register
|
||||
case reportProblem
|
||||
|
||||
case loginDirectlyWithOIDC(data: OIDCAuthorizationDataProxy, window: UIWindow)
|
||||
case loginDirectlyWithPassword(loginHint: String?)
|
||||
}
|
||||
|
||||
enum AuthenticationStartScreenViewModelAction {
|
||||
enum AuthenticationStartScreenViewModelAction: Equatable {
|
||||
case loginWithQR
|
||||
case loginManually
|
||||
case login
|
||||
case register
|
||||
case reportProblem
|
||||
|
||||
case loginDirectlyWithOIDC(data: OIDCAuthorizationDataProxy, window: UIWindow)
|
||||
case loginDirectlyWithPassword(loginHint: String?)
|
||||
}
|
||||
|
||||
struct AuthenticationStartScreenViewState: BindableState {
|
||||
/// The presentation anchor used for OIDC authentication.
|
||||
var window: UIWindow?
|
||||
|
||||
let serverName: String?
|
||||
let showCreateAccountButton: Bool
|
||||
let isQRCodeLoginEnabled: Bool
|
||||
let isBugReportServiceEnabled: Bool
|
||||
let showQRCodeLoginButton: Bool
|
||||
let showReportProblemButton: Bool
|
||||
|
||||
var bindings = AuthenticationStartScreenViewStateBindings()
|
||||
|
||||
var loginButtonTitle: String {
|
||||
if let serverName {
|
||||
L10n.screenOnboardingSignInTo(serverName)
|
||||
} else if showQRCodeLoginButton {
|
||||
L10n.screenOnboardingSignInManually
|
||||
} else {
|
||||
L10n.actionContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthenticationStartScreenViewStateBindings {
|
||||
var alertInfo: AlertInfo<AuthenticationStartScreenAlertType>?
|
||||
}
|
||||
|
||||
enum AuthenticationStartScreenAlertType {
|
||||
case genericError
|
||||
}
|
||||
|
||||
enum AuthenticationStartScreenViewAction {
|
||||
/// Updates the window used as the OIDC presentation anchor.
|
||||
case updateWindow(UIWindow)
|
||||
|
||||
case loginWithQR
|
||||
case loginManually
|
||||
case login
|
||||
case register
|
||||
case reportProblem
|
||||
}
|
||||
|
||||
+83
-6
@@ -11,28 +11,105 @@ import SwiftUI
|
||||
typealias AuthenticationStartScreenViewModelType = StateStoreViewModelV2<AuthenticationStartScreenViewState, AuthenticationStartScreenViewAction>
|
||||
|
||||
class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType, AuthenticationStartScreenViewModelProtocol {
|
||||
private let authenticationService: AuthenticationServiceProtocol
|
||||
private let provisioningParameters: AccountProvisioningParameters?
|
||||
private let appSettings: AppSettings
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
|
||||
private var actionsSubject: PassthroughSubject<AuthenticationStartScreenViewModelAction, Never> = .init()
|
||||
|
||||
var actions: AnyPublisher<AuthenticationStartScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(showCreateAccountButton: Bool, isBugReportServiceEnabled: Bool) {
|
||||
super.init(initialViewState: AuthenticationStartScreenViewState(showCreateAccountButton: showCreateAccountButton,
|
||||
isQRCodeLoginEnabled: !ProcessInfo.processInfo.isiOSAppOnMac,
|
||||
isBugReportServiceEnabled: isBugReportServiceEnabled))
|
||||
init(authenticationService: AuthenticationServiceProtocol,
|
||||
provisioningParameters: AccountProvisioningParameters?,
|
||||
isBugReportServiceEnabled: Bool,
|
||||
appSettings: AppSettings,
|
||||
userIndicatorController: UserIndicatorControllerProtocol) {
|
||||
self.authenticationService = authenticationService
|
||||
self.provisioningParameters = provisioningParameters
|
||||
self.appSettings = appSettings
|
||||
self.userIndicatorController = userIndicatorController
|
||||
|
||||
// We only show the "Sign in to …" button when using a provisioning link.
|
||||
let showCreateAccountButton = appSettings.showCreateAccountButton && provisioningParameters == nil
|
||||
let showQRCodeLoginButton = !ProcessInfo.processInfo.isiOSAppOnMac && provisioningParameters == nil
|
||||
|
||||
super.init(initialViewState: AuthenticationStartScreenViewState(serverName: provisioningParameters?.accountProvider,
|
||||
showCreateAccountButton: showCreateAccountButton,
|
||||
showQRCodeLoginButton: showQRCodeLoginButton,
|
||||
showReportProblemButton: isBugReportServiceEnabled))
|
||||
}
|
||||
|
||||
override func process(viewAction: AuthenticationStartScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .updateWindow(let window):
|
||||
guard state.window != window else { return }
|
||||
state.window = window
|
||||
case .loginWithQR:
|
||||
actionsSubject.send(.loginWithQR)
|
||||
case .loginManually:
|
||||
actionsSubject.send(.loginManually)
|
||||
case .login:
|
||||
Task { await login() }
|
||||
case .register:
|
||||
actionsSubject.send(.register)
|
||||
case .reportProblem:
|
||||
actionsSubject.send(.reportProblem)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func login() async {
|
||||
if let provisioningParameters {
|
||||
await configureProvisionedServer(with: provisioningParameters)
|
||||
} else {
|
||||
actionsSubject.send(.login) // No need to configure anything here, continue the flow.
|
||||
}
|
||||
}
|
||||
|
||||
private func configureProvisionedServer(with provisioningParameters: AccountProvisioningParameters) async {
|
||||
startLoading()
|
||||
defer { stopLoading() }
|
||||
|
||||
guard case .success = await authenticationService.configure(for: provisioningParameters.accountProvider, flow: .login) else {
|
||||
// As the server was provisioned, we don't worry about the specifics and show a generic error to the user.
|
||||
displayError()
|
||||
return
|
||||
}
|
||||
|
||||
guard authenticationService.homeserver.value.loginMode.supportsOIDCFlow else {
|
||||
actionsSubject.send(.loginDirectlyWithPassword(loginHint: provisioningParameters.loginHint))
|
||||
return
|
||||
}
|
||||
|
||||
guard let window = state.window else {
|
||||
displayError()
|
||||
return
|
||||
}
|
||||
|
||||
switch await authenticationService.urlForOIDCLogin(loginHint: provisioningParameters.loginHint) {
|
||||
case .success(let oidcData):
|
||||
actionsSubject.send(.loginDirectlyWithOIDC(data: oidcData, window: window))
|
||||
case .failure:
|
||||
displayError()
|
||||
}
|
||||
}
|
||||
|
||||
private let loadingIndicatorID = "\(AuthenticationStartScreenViewModel.self)-Loading"
|
||||
|
||||
private func startLoading() {
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorID,
|
||||
type: .modal,
|
||||
title: L10n.commonLoading,
|
||||
persistent: true))
|
||||
}
|
||||
|
||||
private func stopLoading() {
|
||||
userIndicatorController.retractIndicatorWithId(loadingIndicatorID)
|
||||
}
|
||||
|
||||
private func displayError() {
|
||||
state.bindings.alertInfo = AlertInfo(id: .genericError)
|
||||
}
|
||||
}
|
||||
|
||||
+17
-7
@@ -35,7 +35,7 @@ struct AuthenticationStartScreen: View {
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if context.viewState.isBugReportServiceEnabled {
|
||||
if context.viewState.showReportProblemButton {
|
||||
Button {
|
||||
context.send(viewAction: .reportProblem)
|
||||
} label: {
|
||||
@@ -45,6 +45,7 @@ struct AuthenticationStartScreen: View {
|
||||
.padding(.bottom)
|
||||
}
|
||||
.frame(width: geometry.size.width)
|
||||
.accessibilityIdentifier(A11yIdentifiers.authenticationStartScreen.reportAProblem)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +53,9 @@ struct AuthenticationStartScreen: View {
|
||||
.background {
|
||||
AuthenticationStartScreenBackgroundImage()
|
||||
}
|
||||
.introspect(.window, on: .supportedVersions) { window in
|
||||
context.send(viewAction: .updateWindow(window))
|
||||
}
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
@@ -89,7 +93,7 @@ struct AuthenticationStartScreen: View {
|
||||
/// The main action buttons.
|
||||
var buttons: some View {
|
||||
VStack(spacing: 16) {
|
||||
if context.viewState.isQRCodeLoginEnabled {
|
||||
if context.viewState.showQRCodeLoginButton {
|
||||
Button { context.send(viewAction: .loginWithQR) } label: {
|
||||
Label(L10n.screenOnboardingSignInWithQrCode, icon: \.qrCode)
|
||||
}
|
||||
@@ -97,8 +101,8 @@ struct AuthenticationStartScreen: View {
|
||||
.accessibilityIdentifier(A11yIdentifiers.authenticationStartScreen.signInWithQr)
|
||||
}
|
||||
|
||||
Button { context.send(viewAction: .loginManually) } label: {
|
||||
Text(context.viewState.isQRCodeLoginEnabled ? L10n.screenOnboardingSignInManually : L10n.actionContinue)
|
||||
Button { context.send(viewAction: .login) } label: {
|
||||
Text(context.viewState.loginButtonTitle)
|
||||
}
|
||||
.buttonStyle(.compound(.primary))
|
||||
.accessibilityIdentifier(A11yIdentifiers.authenticationStartScreen.signIn)
|
||||
@@ -120,16 +124,22 @@ struct AuthenticationStartScreen: View {
|
||||
struct AuthenticationStartScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = makeViewModel()
|
||||
static let bugReportDisabledViewModel = makeViewModel(isBugReportServiceEnabled: false)
|
||||
static let provisionedViewModel = makeViewModel(provisionedServerName: "example.com")
|
||||
|
||||
static var previews: some View {
|
||||
AuthenticationStartScreen(context: viewModel.context)
|
||||
.previewDisplayName("Default")
|
||||
AuthenticationStartScreen(context: bugReportDisabledViewModel.context)
|
||||
.previewDisplayName("Bug report disabled")
|
||||
AuthenticationStartScreen(context: provisionedViewModel.context)
|
||||
.previewDisplayName("Provisioned")
|
||||
}
|
||||
|
||||
static func makeViewModel(isBugReportServiceEnabled: Bool = true) -> AuthenticationStartScreenViewModel {
|
||||
AuthenticationStartScreenViewModel(showCreateAccountButton: true,
|
||||
isBugReportServiceEnabled: isBugReportServiceEnabled)
|
||||
static func makeViewModel(isBugReportServiceEnabled: Bool = true, provisionedServerName: String? = nil) -> AuthenticationStartScreenViewModel {
|
||||
AuthenticationStartScreenViewModel(authenticationService: AuthenticationService.mock,
|
||||
provisioningParameters: provisionedServerName.map { .init(accountProvider: $0, loginHint: nil) },
|
||||
isBugReportServiceEnabled: isBugReportServiceEnabled,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ struct BugReportScreen: View {
|
||||
Button(L10n.actionCancel) {
|
||||
context.send(viewAction: .cancel)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.bugReportScreen.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user