From 33fcb8e6675353c30f1598874a41379b5ba8e089 Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Tue, 20 May 2025 11:09:50 +0100 Subject: [PATCH] Allow the app to be configured to bypass the server selection screen. (#4131) * Make account provider configuration more flexible. - Change defaultHomeserverAddress to an array of providers (needs UI). - Add allowOtherAccountProviders to prevent the user from manually entering a provider. * Refactor QR code scan failures into a common type. * Validate scanned QR codes against the allowed account providers. * Hide the login flow on the QR code screen when restricted. --- .../Sources/Application/AppSettings.swift | 16 ++-- .../AuthenticationFlowCoordinator.swift | 55 ++++++++------ .../AuthenticationStartScreenViewModel.swift | 48 ++++++++---- .../QRCodeLoginScreenCoordinator.swift | 2 + .../QRCodeLoginScreenModels.swift | 73 +++++++++++-------- .../QRCodeLoginScreenViewModel.swift | 17 +++-- .../View/QRCodeLoginScreen.swift | 51 ++++++------- .../AuthenticationService.swift | 4 +- .../Services/QRCode/QRCodeLoginService.swift | 10 +++ .../QRCode/QRCodeLoginServiceProtocol.swift | 1 + .../UITests/UITestsAppCoordinator.swift | 27 ++++++- .../UITests/UITestsScreenIdentifier.swift | 1 + Enterprise | 2 +- ...unsupported-restricted-flow-iPad-en-GB.png | 3 + ...nsupported-restricted-flow-iPad-pseudo.png | 3 + ...ported-restricted-flow-iPhone-16-en-GB.png | 3 + ...orted-restricted-flow-iPhone-16-pseudo.png | 3 + ...CodeLoginScreen.Not-allowed-iPad-en-GB.png | 3 + ...odeLoginScreen.Not-allowed-iPad-pseudo.png | 3 + ...oginScreen.Not-allowed-iPhone-16-en-GB.png | 3 + ...ginScreen.Not-allowed-iPhone-16-pseudo.png | 3 + .../AuthenticationFlowCoordinatorTests.swift | 29 ++++++++ ...viderLoginWithPassword-iPad-18-4-en-GB.png | 3 + ...derLoginWithPassword-iPhone-18-4-en-GB.png | 3 + ...henticationStartScreenViewModelTests.swift | 70 ++++++++++++++++++ .../QRCodeLoginScreenViewModelTests.swift | 1 + 26 files changed, 326 insertions(+), 111 deletions(-) create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Linking-unsupported-restricted-flow-iPad-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Linking-unsupported-restricted-flow-iPad-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Linking-unsupported-restricted-flow-iPhone-16-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Linking-unsupported-restricted-flow-iPhone-16-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPad-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPad-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPhone-16-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPhone-16-pseudo.png create mode 100644 UITests/Sources/__Snapshots__/Application/authenticationFlowCoordinator.testSingleProviderLoginWithPassword-iPad-18-4-en-GB.png create mode 100644 UITests/Sources/__Snapshots__/Application/authenticationFlowCoordinator.testSingleProviderLoginWithPassword-iPhone-18-4-en-GB.png diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 5accb897a..9e1a1abe3 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -92,7 +92,8 @@ final class AppSettings { // MARK: - Hooks // swiftlint:disable:next function_parameter_count - func override(defaultHomeserverAddress: String, + func override(accountProviders: [String], + allowOtherAccountProviders: Bool, pushGatewayBaseURL: URL, oidcRedirectURL: URL, websiteURL: URL, @@ -109,7 +110,8 @@ final class AppSettings { bugReportApplicationID: String, analyticsTermsURL: URL?, mapTilerConfiguration: MapTilerConfiguration) { - self.defaultHomeserverAddress = defaultHomeserverAddress + self.accountProviders = accountProviders + self.allowOtherAccountProviders = allowOtherAccountProviders self.pushGatewayBaseURL = pushGatewayBaseURL self.oidcRedirectURL = oidcRedirectURL self.websiteURL = websiteURL @@ -151,9 +153,13 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.seenInvites, defaultValue: [], storageType: .userDefaults(store)) var seenInvites: Set - /// The default homeserver address used. This is intentionally a string without a scheme - /// so that it can be passed to Rust as a ServerName for well-known discovery. - private(set) var defaultHomeserverAddress = "matrix.org" + /// The initial set of account providers shown to the user in the authentication flow. + /// + /// Account provider is the friendly term for the server name. It should not contain an `https` prefix and should + /// match the last part of the user ID. For example `example.com` and not `https://matrix.example.com`. + private(set) var accountProviders = ["matrix.org"] + /// Whether or not the user is allowed to manually enter their own account provider or must select from one of `defaultAccountProviders`. + private(set) var allowOtherAccountProviders = true /// The task identifier used for background app refresh. Also used in main target's the Info.plist let backgroundAppRefreshTaskIdentifier = "io.element.elementx.background.refresh" diff --git a/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift index f39f40f13..749fd1e84 100644 --- a/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift @@ -31,8 +31,8 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { /// The initial screen shown when you first launch the app. case startScreen - /// The initial screen with a provisioning link applied. - case provisionedStartScreen + /// The initial screen with the selection of account provider having been restricted. + case restrictedStartScreen /// The screen used for the whole QR Code flow. case qrCodeLoginScreen @@ -55,7 +55,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { enum Event: EventType { /// The flow is being started. - case start + case start(allowOtherAccountProviders: Bool) /// Modify the flow using the provisioning parameters in the `userInfo`. case applyProvisioningParameters @@ -68,7 +68,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { case reportProblem /// The QR login flow was aborted. - case cancelledLoginWithQR + case cancelledLoginWithQR(previousState: State) /// The user aborted manual login. case cancelledServerConfirmation @@ -127,12 +127,17 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { } func start() { - stateMachine.tryEvent(.start) + stateMachine.tryEvent(.start(allowOtherAccountProviders: appSettings.allowOtherAccountProviders)) } func handleAppRoute(_ appRoute: AppRoute, animated: Bool) { switch appRoute { case .accountProvisioningLink(let provisioningParameters): + guard appSettings.allowOtherAccountProviders else { + MXLog.error("Provisioning links not allowed, ignoring.") + return + } + if stateMachine.state != .startScreen { clearRoute(animated: animated) } @@ -145,11 +150,11 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { func clearRoute(animated: Bool) { switch stateMachine.state { - case .initial, .startScreen, .provisionedStartScreen: + case .initial, .startScreen, .restrictedStartScreen: break case .qrCodeLoginScreen: navigationStackCoordinator.setSheetCoordinator(nil) - stateMachine.tryEvent(.cancelledLoginWithQR) // Needs to be handled manually. + stateMachine.tryEvent(.cancelledLoginWithQR(previousState: .initial)) // Needs to be handled manually. case .serverConfirmationScreen: navigationStackCoordinator.popToRoot(animated: animated) case .serverSelectionScreen: @@ -170,22 +175,27 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { // MARK: - Setup private func configureStateMachine() { - stateMachine.addRoutes(event: .start, transitions: [.initial => .startScreen]) { [weak self] _ in + stateMachine.addRoutes(event: .start(allowOtherAccountProviders: true), transitions: [.initial => .startScreen]) { [weak self] _ in + self?.showStartScreen(fromState: .initial) + } + stateMachine.addRoutes(event: .start(allowOtherAccountProviders: false), transitions: [.initial => .restrictedStartScreen]) { [weak self] _ in self?.showStartScreen(fromState: .initial) } - stateMachine.addRoutes(event: .applyProvisioningParameters, transitions: [.initial => .provisionedStartScreen, - .startScreen => .provisionedStartScreen]) { [weak self] context in + stateMachine.addRoutes(event: .applyProvisioningParameters, transitions: [.initial => .restrictedStartScreen, + .startScreen => .restrictedStartScreen]) { [weak self] context in guard let provisioningParameters = context.userInfo as? AccountProvisioningParameters else { fatalError("The authentication configuration is missing.") } self?.showStartScreen(fromState: context.fromState, applying: provisioningParameters) } // QR Code - stateMachine.addRoutes(event: .loginWithQR, transitions: [.startScreen => .qrCodeLoginScreen]) { [weak self] _ in - self?.showQRCodeLoginScreen() + stateMachine.addRoutes(event: .loginWithQR, transitions: [.startScreen => .qrCodeLoginScreen, + .restrictedStartScreen => .qrCodeLoginScreen]) { [weak self] context in + self?.showQRCodeLoginScreen(fromState: context.fromState) } - stateMachine.addRoutes(event: .cancelledLoginWithQR, transitions: [.qrCodeLoginScreen => .startScreen]) + stateMachine.addRoutes(event: .cancelledLoginWithQR(previousState: .startScreen), transitions: [.qrCodeLoginScreen => .startScreen]) + stateMachine.addRoutes(event: .cancelledLoginWithQR(previousState: .restrictedStartScreen), transitions: [.qrCodeLoginScreen => .restrictedStartScreen]) // Manual Authentication @@ -206,31 +216,31 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { stateMachine.addRoutes(event: .dismissedServerSelection, transitions: [.serverSelectionScreen => .serverConfirmationScreen]) stateMachine.addRoutes(event: .continueWithOIDC, transitions: [.serverConfirmationScreen => .oidcAuthentication, - .provisionedStartScreen => .oidcAuthentication]) { [weak self] context in + .restrictedStartScreen => .oidcAuthentication]) { [weak self] context in guard let (oidcData, window) = context.userInfo as? (OIDCAuthorizationDataProxy, UIWindow) else { fatalError("Missing the OIDC data and presentation anchor.") } self?.showOIDCAuthentication(oidcData: oidcData, presentationAnchor: window, fromState: context.fromState) } stateMachine.addRoutes(event: .cancelledOIDCAuthentication(previousState: .serverConfirmationScreen), transitions: [.oidcAuthentication => .serverConfirmationScreen]) - stateMachine.addRoutes(event: .cancelledOIDCAuthentication(previousState: .provisionedStartScreen), transitions: [.oidcAuthentication => .provisionedStartScreen]) + stateMachine.addRoutes(event: .cancelledOIDCAuthentication(previousState: .restrictedStartScreen), transitions: [.oidcAuthentication => .restrictedStartScreen]) stateMachine.addRoutes(event: .continueWithPassword, transitions: [.serverConfirmationScreen => .loginScreen, - .provisionedStartScreen => .loginScreen]) { [weak self] context in + .restrictedStartScreen => .loginScreen]) { [weak self] context in let loginHint = context.userInfo as? String self?.showLoginScreen(loginHint: loginHint, fromState: context.fromState) } stateMachine.addRoutes(event: .cancelledPasswordLogin(previousState: .serverConfirmationScreen), transitions: [.loginScreen => .serverConfirmationScreen]) - stateMachine.addRoutes(event: .cancelledPasswordLogin(previousState: .provisionedStartScreen), transitions: [.loginScreen => .provisionedStartScreen]) + stateMachine.addRoutes(event: .cancelledPasswordLogin(previousState: .restrictedStartScreen), transitions: [.loginScreen => .restrictedStartScreen]) // Bug Report stateMachine.addRoutes(event: .reportProblem, transitions: [.startScreen => .bugReportFlow, - .provisionedStartScreen => .bugReportFlow]) { [weak self] context in + .restrictedStartScreen => .bugReportFlow]) { [weak self] context in self?.startBugReportFlow(fromState: context.fromState) } stateMachine.addRoutes(event: .bugReportFlowComplete(previousState: .startScreen), transitions: [.bugReportFlow => .startScreen]) - stateMachine.addRoutes(event: .bugReportFlowComplete(previousState: .provisionedStartScreen), transitions: [.bugReportFlow => .provisionedStartScreen]) + stateMachine.addRoutes(event: .bugReportFlowComplete(previousState: .restrictedStartScreen), transitions: [.bugReportFlow => .restrictedStartScreen]) // Completion @@ -292,8 +302,9 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { // MARK: - QR Code - private func showQRCodeLoginScreen() { + private func showQRCodeLoginScreen(fromState: State) { let coordinator = QRCodeLoginScreenCoordinator(parameters: .init(qrCodeLoginService: qrCodeLoginService, + canSignInManually: fromState != .restrictedStartScreen, orientationManager: appMediator.windowManager, appMediator: appMediator)) coordinator.actionsPublisher.sink { [weak self] action in @@ -303,11 +314,11 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { switch action { case .signInManually: navigationStackCoordinator.setSheetCoordinator(nil) - stateMachine.tryEvent(.cancelledLoginWithQR) + stateMachine.tryEvent(.cancelledLoginWithQR(previousState: fromState)) stateMachine.tryEvent(.confirmServer(.login)) case .cancel: navigationStackCoordinator.setSheetCoordinator(nil) - stateMachine.tryEvent(.cancelledLoginWithQR) + stateMachine.tryEvent(.cancelledLoginWithQR(previousState: fromState)) case .done(let userSession): navigationStackCoordinator.setSheetCoordinator(nil) // Since the qr code login flow includes verification diff --git a/ElementX/Sources/Screens/AuthenticationStartScreen/AuthenticationStartScreenViewModel.swift b/ElementX/Sources/Screens/AuthenticationStartScreen/AuthenticationStartScreenViewModel.swift index e4ffa70a4..e58bb4487 100644 --- a/ElementX/Sources/Screens/AuthenticationStartScreen/AuthenticationStartScreenViewModel.swift +++ b/ElementX/Sources/Screens/AuthenticationStartScreen/AuthenticationStartScreenViewModel.swift @@ -32,14 +32,30 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType 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 + let isQRCodeScanningSupported = !ProcessInfo.processInfo.isiOSAppOnMac - super.init(initialViewState: AuthenticationStartScreenViewState(serverName: provisioningParameters?.accountProvider, - showCreateAccountButton: showCreateAccountButton, - showQRCodeLoginButton: showQRCodeLoginButton, - showReportProblemButton: isBugReportServiceEnabled)) + 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, + showCreateAccountButton: false, + showQRCodeLoginButton: isQRCodeScanningSupported, + showReportProblemButton: isBugReportServiceEnabled) + } else if let provisioningParameters { + // We only show the "Sign in to …" button when using a provisioning link. + AuthenticationStartScreenViewState(serverName: provisioningParameters.accountProvider, + showCreateAccountButton: false, + showQRCodeLoginButton: false, + showReportProblemButton: isBugReportServiceEnabled) + } else { + // The default configuration. + AuthenticationStartScreenViewState(serverName: nil, + showCreateAccountButton: appSettings.showCreateAccountButton, + showQRCodeLoginButton: isQRCodeScanningSupported, + showReportProblemButton: isBugReportServiceEnabled) + } + + super.init(initialViewState: initialViewState) } override func process(viewAction: AuthenticationStartScreenViewAction) { @@ -61,25 +77,31 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType // MARK: - Private private func login() async { - if let provisioningParameters { - await configureProvisionedServer(with: provisioningParameters) + if !appSettings.allowOtherAccountProviders { + if appSettings.accountProviders.count == 1 { + await configureAccountProvider(appSettings.accountProviders[0]) + } else { + fatalError("WIP: Account provider picker not implemented.") + } + } else if let provisioningParameters { + await configureAccountProvider(provisioningParameters.accountProvider, loginHint: provisioningParameters.loginHint) } else { actionsSubject.send(.login) // No need to configure anything here, continue the flow. } } - private func configureProvisionedServer(with provisioningParameters: AccountProvisioningParameters) async { + private func configureAccountProvider(_ accountProvider: String, loginHint: String? = nil) async { startLoading() defer { stopLoading() } - guard case .success = await authenticationService.configure(for: provisioningParameters.accountProvider, flow: .login) else { + guard case .success = await authenticationService.configure(for: 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)) + actionsSubject.send(.loginDirectlyWithPassword(loginHint: loginHint)) return } @@ -88,7 +110,7 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType return } - switch await authenticationService.urlForOIDCLogin(loginHint: provisioningParameters.loginHint) { + switch await authenticationService.urlForOIDCLogin(loginHint: loginHint) { case .success(let oidcData): actionsSubject.send(.loginDirectlyWithOIDC(data: oidcData, window: window)) case .failure: diff --git a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenCoordinator.swift b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenCoordinator.swift index 52641c501..8a714e510 100644 --- a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenCoordinator.swift +++ b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenCoordinator.swift @@ -10,6 +10,7 @@ import SwiftUI struct QRCodeLoginScreenCoordinatorParameters { let qrCodeLoginService: QRCodeLoginServiceProtocol + let canSignInManually: Bool let orientationManager: OrientationManagerProtocol let appMediator: AppMediatorProtocol } @@ -33,6 +34,7 @@ final class QRCodeLoginScreenCoordinator: CoordinatorProtocol { init(parameters: QRCodeLoginScreenCoordinatorParameters) { viewModel = QRCodeLoginScreenViewModel(qrCodeLoginService: parameters.qrCodeLoginService, + canSignInManually: parameters.canSignInManually, appMediator: parameters.appMediator) orientationManager = parameters.orientationManager } diff --git a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenModels.swift b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenModels.swift index 0597fb13f..11a14f712 100644 --- a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenModels.swift +++ b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenModels.swift @@ -15,6 +15,9 @@ enum QRCodeLoginScreenViewModelAction { struct QRCodeLoginScreenViewState: BindableState { var state: QRCodeLoginState = .initial + /// Whether or not it is possible for the screen to start the manual sign in flow. This was added to avoid + /// having to handle server configuration when ``AppSettings.allowOtherAccountProviders`` is false. + let canSignInManually: Bool private static let initialStateListItem3AttributedText = { let boldPlaceholder = "{bold}" @@ -74,14 +77,43 @@ enum QRCodeLoginState: Equatable { } enum QRCodeLoginScanningState: Equatable { - /// the qr code is scanning + /// the QR code is scanning case scanning - /// the qr code has been detected and is being processed + /// the QR code has been detected and is being processed case connecting - /// the qr code has been processed and is invalid - case invalid - /// the qr code has been processed but it belongs to a device not signed in, - case deviceNotSignedIn + /// the QR code was scanned, but an error occurred. + case scanFailed(Error) + + enum Error: Equatable { + /// the QR code has been processed and is invalid + case invalid + /// the QR code has been processed but it is for an account provider that isn't allowed. + case notAllowed(scannedProvider: String, allowedProviders: [String]) + /// the QR code has been processed but it belongs to a device not signed in + case deviceNotSignedIn + + var title: String { + switch self { + case .invalid: + L10n.screenQrCodeLoginInvalidScanStateSubtitle + case .notAllowed(let scannedProvider, _): + L10n.screenChangeServerErrorUnauthorizedHomeserverTitle(scannedProvider) + case .deviceNotSignedIn: + L10n.screenQrCodeLoginDeviceNotSignedInScanStateSubtitle + } + } + + var description: String { + switch self { + case .invalid: + L10n.screenQrCodeLoginInvalidScanStateDescription + case .notAllowed(_, let allowedProviders): + L10n.screenChangeServerErrorUnauthorizedHomeserverContent(allowedProviders.formatted(.list(type: .and))) + case .deviceNotSignedIn: + L10n.screenQrCodeLoginDeviceNotSignedInScanStateDescription + } + } + } } enum QRCodeLoginDisplayCodeState: Equatable { @@ -100,39 +132,22 @@ enum QRCodeLoginState: Equatable { var isScanning: Bool { switch self { - case .scan(let state): - return state == .scanning - default: - return false + case .scan(.scanning): true + default: false } } var isError: Bool { switch self { - case .error: - return true - case let .scan(state): - switch state { - case .invalid, .deviceNotSignedIn: - return true - default: - return false - } - default: - return false + case .error, .scan(.scanFailed): true + default: false } } var shouldDisplayCancelButton: Bool { switch self { - case .initial: - return true - case .scan: - return true - case .error(let error): - return error == .noCameraPermission - default: - return false + case .initial, .scan, .error(.noCameraPermission): true + default: false } } } diff --git a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenViewModel.swift b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenViewModel.swift index 0b318c18a..70f4d0faa 100644 --- a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenViewModel.swift +++ b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenViewModel.swift @@ -22,10 +22,11 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr private var scanTask: Task? init(qrCodeLoginService: QRCodeLoginServiceProtocol, + canSignInManually: Bool, appMediator: AppMediatorProtocol) { self.qrCodeLoginService = qrCodeLoginService self.appMediator = appMediator - super.init(initialViewState: QRCodeLoginScreenViewState()) + super.init(initialViewState: QRCodeLoginScreenViewState(canSignInManually: canSignInManually)) setupSubscriptions() } @@ -119,9 +120,11 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr MXLog.error("Failed to scan the QR code: \(error)") switch error { case .invalidQRCode: - state.state = .scan(.invalid) + state.state = .scan(.scanFailed(.invalid)) + case .providerNotAllowed(let scannedProvider, let allowedProviders): + state.state = .scan(.scanFailed(.notAllowed(scannedProvider: scannedProvider, allowedProviders: allowedProviders))) case .deviceNotSignedIn: - state.state = .scan(.deviceNotSignedIn) + state.state = .scan(.scanFailed(.deviceNotSignedIn)) case .cancelled: state.state = .error(.cancelled) case .connectionInsecure: @@ -140,15 +143,15 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr } /// Only for mocking initial states - fileprivate init(state: QRCodeLoginState) { + fileprivate init(state: QRCodeLoginState, canSignInManually: Bool) { qrCodeLoginService = QRCodeLoginServiceMock() appMediator = AppMediatorMock.default - super.init(initialViewState: .init(state: state)) + super.init(initialViewState: .init(state: state, canSignInManually: canSignInManually)) } } extension QRCodeLoginScreenViewModel { - static func mock(state: QRCodeLoginState) -> QRCodeLoginScreenViewModel { - QRCodeLoginScreenViewModel(state: state) + static func mock(state: QRCodeLoginState, canSignInManually: Bool = true) -> QRCodeLoginScreenViewModel { + QRCodeLoginScreenViewModel(state: state, canSignInManually: canSignInManually) } } diff --git a/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeLoginScreen.swift b/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeLoginScreen.swift index 72620576d..8ad0fa12b 100644 --- a/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeLoginScreen.swift +++ b/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeLoginScreen.swift @@ -169,7 +169,7 @@ struct QRCodeLoginScreen: View { Button("") { } .buttonStyle(.compound(.primary)) .hidden() - case .invalid: + case .scanFailed(let error): VStack(spacing: 16) { Button(L10n.screenQrCodeLoginInvalidScanStateRetryButton) { context.send(viewAction: .startScan) @@ -177,7 +177,7 @@ struct QRCodeLoginScreen: View { .buttonStyle(.compound(.primary)) VStack(spacing: 4) { - Label(L10n.screenQrCodeLoginInvalidScanStateSubtitle, + Label(error.title, icon: \.errorSolid, iconSize: .medium, relativeTo: .compound.bodyMDSemibold) @@ -185,33 +185,12 @@ struct QRCodeLoginScreen: View { .font(.compound.bodyMDSemibold) .foregroundColor(.compound.textCriticalPrimary) - Text(L10n.screenQrCodeLoginInvalidScanStateDescription) - .foregroundColor(.compound.textSecondary) - .font(.compound.bodySM) - .multilineTextAlignment(.center) - } - } - case .deviceNotSignedIn: - VStack(spacing: 16) { - Button(L10n.screenQrCodeLoginInvalidScanStateRetryButton) { - context.send(viewAction: .startScan) - } - .buttonStyle(.compound(.primary)) - - VStack(spacing: 4) { - Label(L10n.screenQrCodeLoginDeviceNotSignedInScanStateSubtitle, - icon: \.errorSolid, - iconSize: .medium, - relativeTo: .compound.bodyMDSemibold) - .labelStyle(.custom(spacing: 10)) - .font(.compound.bodyMDSemibold) - .foregroundColor(.compound.textCriticalPrimary) - - Text(L10n.screenQrCodeLoginDeviceNotSignedInScanStateDescription) + Text(error.description) .foregroundColor(.compound.textSecondary) .font(.compound.bodySM) .multilineTextAlignment(.center) } + .fixedSize(horizontal: false, vertical: true) } } } @@ -375,10 +354,12 @@ struct QRCodeLoginScreen: View { .buttonStyle(.compound(.primary)) case .linkingNotSupported: VStack(spacing: 16) { - Button(L10n.screenOnboardingSignInManually) { - context.send(viewAction: .signInManually) + if context.viewState.canSignInManually { + Button(L10n.screenOnboardingSignInManually) { + context.send(viewAction: .signInManually) + } + .buttonStyle(.compound(.primary)) } - .buttonStyle(.compound(.primary)) Button(L10n.actionCancel) { context.send(viewAction: .cancel) @@ -425,9 +406,13 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview { static let connectingStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.connecting)) - static let invalidStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.invalid)) + static let invalidStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanFailed(.invalid))) - static let deviceNotSignedInStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.deviceNotSignedIn)) + static let notAllowedStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanFailed(.notAllowed(scannedProvider: "evil.com", + allowedProviders: ["example.com", + "server.net"])))) + + static let deviceNotSignedInStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanFailed(.deviceNotSignedIn))) // Display Code static let deviceCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.deviceCode("12"))) @@ -440,6 +425,7 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview { static let connectionNotSecureStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.connectionNotSecure)) static let linkingUnsupportedStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.linkingNotSupported)) + static let linkingUnsupportedRestrictedFlowViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.linkingNotSupported), canSignInManually: false) static let cancelledStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.cancelled)) @@ -464,6 +450,9 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview { QRCodeLoginScreen(context: invalidStateViewModel.context) .previewDisplayName("Invalid") + QRCodeLoginScreen(context: notAllowedStateViewModel.context) + .previewDisplayName("Not allowed") + QRCodeLoginScreen(context: deviceNotSignedInStateViewModel.context) .previewDisplayName("Device not signed in") @@ -481,6 +470,8 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview { QRCodeLoginScreen(context: linkingUnsupportedStateViewModel.context) .previewDisplayName("Linking unsupported") + QRCodeLoginScreen(context: linkingUnsupportedRestrictedFlowViewModel.context) + .previewDisplayName("Linking unsupported restricted flow") QRCodeLoginScreen(context: cancelledStateViewModel.context) .previewDisplayName("Cancelled") diff --git a/ElementX/Sources/Services/Authentication/AuthenticationService.swift b/ElementX/Sources/Services/Authentication/AuthenticationService.swift index 9aaa08034..c16aaf3ba 100644 --- a/ElementX/Sources/Services/Authentication/AuthenticationService.swift +++ b/ElementX/Sources/Services/Authentication/AuthenticationService.swift @@ -36,7 +36,7 @@ class AuthenticationService: AuthenticationServiceProtocol { self.appHooks = appHooks // When updating these, don't forget to update the reset method too. - homeserverSubject = .init(LoginHomeserver(address: appSettings.defaultHomeserverAddress, loginMode: .unknown)) + homeserverSubject = .init(LoginHomeserver(address: appSettings.accountProviders[0], loginMode: .unknown)) flow = .login } @@ -146,7 +146,7 @@ class AuthenticationService: AuthenticationServiceProtocol { } func reset() { - homeserverSubject.send(LoginHomeserver(address: appSettings.defaultHomeserverAddress, loginMode: .unknown)) + homeserverSubject.send(LoginHomeserver(address: appSettings.accountProviders[0], loginMode: .unknown)) flow = .login client = nil } diff --git a/ElementX/Sources/Services/QRCode/QRCodeLoginService.swift b/ElementX/Sources/Services/QRCode/QRCodeLoginService.swift index fb83e3200..319fe2546 100644 --- a/ElementX/Sources/Services/QRCode/QRCodeLoginService.swift +++ b/ElementX/Sources/Services/QRCode/QRCodeLoginService.swift @@ -43,6 +43,16 @@ final class QRCodeLoginService: QRCodeLoginServiceProtocol { return .failure(.invalidQRCode) } + guard let scannedServerName = qrData.serverName() else { + MXLog.error("The QR code is from a device that is not yet signed in.") + return .failure(.deviceNotSignedIn) + } + + if !appSettings.allowOtherAccountProviders, !appSettings.accountProviders.contains(scannedServerName) { + MXLog.error("The scanned device's server is not allowed: \(scannedServerName)") + return .failure(.providerNotAllowed(scannedProvider: scannedServerName, allowedProviders: appSettings.accountProviders)) + } + let listener = SDKListener { [weak self] progress in self?.qrLoginProgressSubject.send(progress) } diff --git a/ElementX/Sources/Services/QRCode/QRCodeLoginServiceProtocol.swift b/ElementX/Sources/Services/QRCode/QRCodeLoginServiceProtocol.swift index 10ae8845e..957de28fe 100644 --- a/ElementX/Sources/Services/QRCode/QRCodeLoginServiceProtocol.swift +++ b/ElementX/Sources/Services/QRCode/QRCodeLoginServiceProtocol.swift @@ -13,6 +13,7 @@ import MatrixRustSDK enum QRCodeLoginServiceError: Error { case failedLoggingIn case invalidQRCode + case providerNotAllowed(scannedProvider: String, allowedProviders: [String]) case cancelled case connectionInsecure case declined diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index ddc7e1146..371d4dd4f 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -120,13 +120,36 @@ class MockScreen: Identifiable { userIndicatorController: ServiceLocator.shared.userIndicatorController)) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator - case .authenticationFlow, .provisionedAuthenticationFlow: + case .authenticationFlow, .provisionedAuthenticationFlow, .singleProviderAuthenticationFlow: + let appSettings: AppSettings! = ServiceLocator.shared.settings + + if id == .singleProviderAuthenticationFlow { + appSettings.override(accountProviders: ["example.com"], + allowOtherAccountProviders: false, + pushGatewayBaseURL: appSettings.pushGatewayBaseURL, + oidcRedirectURL: appSettings.oidcRedirectURL, + websiteURL: appSettings.websiteURL, + logoURL: appSettings.logoURL, + copyrightURL: appSettings.copyrightURL, + acceptableUseURL: appSettings.acceptableUseURL, + privacyURL: appSettings.privacyURL, + encryptionURL: appSettings.encryptionURL, + deviceVerificationURL: appSettings.deviceVerificationURL, + chatBackupDetailsURL: appSettings.chatBackupDetailsURL, + identityPinningViolationDetailsURL: appSettings.identityPinningViolationDetailsURL, + elementWebHosts: appSettings.elementWebHosts, + accountProvisioningHost: appSettings.accountProvisioningHost, + bugReportApplicationID: appSettings.bugReportApplicationID, + analyticsTermsURL: appSettings.analyticsTermsURL, + mapTilerConfiguration: appSettings.mapTilerConfiguration) + } + let flowCoordinator = AuthenticationFlowCoordinator(authenticationService: AuthenticationService.mock, qrCodeLoginService: QRCodeLoginServiceMock(), bugReportService: BugReportServiceMock(.init()), navigationRootCoordinator: navigationRootCoordinator, appMediator: AppMediatorMock.default, - appSettings: ServiceLocator.shared.settings, + appSettings: appSettings, analytics: ServiceLocator.shared.analytics, userIndicatorController: ServiceLocator.shared.userIndicatorController) flowCoordinator.start() diff --git a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift index 79c194ed5..fcc6921f2 100644 --- a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift +++ b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift @@ -17,6 +17,7 @@ enum UITestsScreenIdentifier: String { case appLockSetupFlowUnlock case authenticationFlow case provisionedAuthenticationFlow + case singleProviderAuthenticationFlow case bugReport case createPoll case createRoom diff --git a/Enterprise b/Enterprise index e004345aa..e2f589c18 160000 --- a/Enterprise +++ b/Enterprise @@ -1 +1 @@ -Subproject commit e004345aa506697ca3cc4ee5d842492143d71e5c +Subproject commit e2f589c1886666774ab688b8140a924a038379bc diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Linking-unsupported-restricted-flow-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Linking-unsupported-restricted-flow-iPad-en-GB.png new file mode 100644 index 000000000..c2899150d --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Linking-unsupported-restricted-flow-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4624b04341dd9beefcf689a39b047c9d6ebea797d68996e4ea669b45d6494faf +size 112957 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Linking-unsupported-restricted-flow-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Linking-unsupported-restricted-flow-iPad-pseudo.png new file mode 100644 index 000000000..17c6593d3 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Linking-unsupported-restricted-flow-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:58643fdc4bc95fda9cdc91d33b9b98973c6b67de256ca800a2babece767808d2 +size 134945 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Linking-unsupported-restricted-flow-iPhone-16-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Linking-unsupported-restricted-flow-iPhone-16-en-GB.png new file mode 100644 index 000000000..cf115d909 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Linking-unsupported-restricted-flow-iPhone-16-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:75e324ee678e506005f852ac377fc02281ee60335c3f996df7528335b9cc255d +size 66133 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Linking-unsupported-restricted-flow-iPhone-16-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Linking-unsupported-restricted-flow-iPhone-16-pseudo.png new file mode 100644 index 000000000..c54c4ccab --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Linking-unsupported-restricted-flow-iPhone-16-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5407ba8180475d70b3aee7fdcb6a2e8e84fb6977a29b1b223566c3d31872ec2 +size 93700 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPad-en-GB.png new file mode 100644 index 000000000..8b80cca5e --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ecff6f7fb1e12a42c25761e0b83658550e62e6ec4a8fca1e239b63d5851cf31 +size 122577 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPad-pseudo.png new file mode 100644 index 000000000..202dc671f --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77a3fbc2dd4fd8f6c45c205bff44b5544b4e1d368e0457f6e9ae4b17ecca9b7f +size 132753 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPhone-16-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPhone-16-en-GB.png new file mode 100644 index 000000000..0554411b6 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPhone-16-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:033db8a5d3b9b0cd70b8c8b01856dddf5703f11be3027fe5ac1d1c105ab8f1b6 +size 67172 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPhone-16-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPhone-16-pseudo.png new file mode 100644 index 000000000..259030529 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPhone-16-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a355d8275b8be088bfab2b61a630fc164eb6ea3537ad335435df80b899615c48 +size 84715 diff --git a/UITests/Sources/AuthenticationFlowCoordinatorTests.swift b/UITests/Sources/AuthenticationFlowCoordinatorTests.swift index 0310c6c92..7afec3768 100644 --- a/UITests/Sources/AuthenticationFlowCoordinatorTests.swift +++ b/UITests/Sources/AuthenticationFlowCoordinatorTests.swift @@ -161,6 +161,35 @@ class AuthenticationFlowCoordinatorUITests: XCTestCase { // Splash Screen: Tap get started button app.buttons[A11yIdentifiers.authenticationStartScreen.signIn].tap() + // No server selection should be shown here + + // Login Screen: Wait for continue button to appear + let continueButton = app.buttons[A11yIdentifiers.loginScreen.continue] + XCTAssertTrue(continueButton.waitForExistence(timeout: 2.0)) + + // Login Screen: Enter valid credentials + app.textFields[A11yIdentifiers.loginScreen.emailUsername].clearAndTypeText("alice\n", app: app) + app.secureTextFields[A11yIdentifiers.loginScreen.password].clearAndTypeText("12345678", app: app) + + // Login Screen: Tap next + app.buttons[A11yIdentifiers.loginScreen.continue].tap() + } + + func testSingleProviderLoginWithPassword() async throws { + // Given the authentication flow with a single supported server. + let app = Application.launch(.singleProviderAuthenticationFlow) + + // Then the start screen should be configured appropriately. + try await app.assertScreenshot() + + // Check the bug report flow works. + try await verifyReportBugButton(app) + + // Splash Screen: Tap get started button + app.buttons[A11yIdentifiers.authenticationStartScreen.signIn].tap() + + // No server selection should be shown here + // Login Screen: Wait for continue button to appear let continueButton = app.buttons[A11yIdentifiers.loginScreen.continue] XCTAssertTrue(continueButton.waitForExistence(timeout: 2.0)) diff --git a/UITests/Sources/__Snapshots__/Application/authenticationFlowCoordinator.testSingleProviderLoginWithPassword-iPad-18-4-en-GB.png b/UITests/Sources/__Snapshots__/Application/authenticationFlowCoordinator.testSingleProviderLoginWithPassword-iPad-18-4-en-GB.png new file mode 100644 index 000000000..446ef1685 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/authenticationFlowCoordinator.testSingleProviderLoginWithPassword-iPad-18-4-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9f9b378ca59a6976c0a5ac40d1b670f5ed813334095f538e63cae6c539311fb0 +size 1337155 diff --git a/UITests/Sources/__Snapshots__/Application/authenticationFlowCoordinator.testSingleProviderLoginWithPassword-iPhone-18-4-en-GB.png b/UITests/Sources/__Snapshots__/Application/authenticationFlowCoordinator.testSingleProviderLoginWithPassword-iPhone-18-4-en-GB.png new file mode 100644 index 000000000..243632cf0 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/authenticationFlowCoordinator.testSingleProviderLoginWithPassword-iPhone-18-4-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:208e50e192c288069dd95ee88619b593b96b203a511c92539c27ddf0982a5aa4 +size 1210790 diff --git a/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift b/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift index 9e8d11d23..77b66ce7e 100644 --- a/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift +++ b/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift @@ -18,6 +18,16 @@ class AuthenticationStartScreenViewModelTests: XCTestCase { var viewModel: AuthenticationStartScreenViewModel! var context: AuthenticationStartScreenViewModel.Context { viewModel.context } + override func setUp() { + AppSettings.resetAllSettings() + let appSettings = AppSettings() + ServiceLocator.shared.register(appSettings: appSettings) + } + + override func tearDown() { + AppSettings.resetAllSettings() + } + func testInitialState() async throws { // Given a view model that has no provisioning parameters. setupViewModel() @@ -80,6 +90,44 @@ class AuthenticationStartScreenViewModelTests: XCTestCase { XCTAssertEqual(authenticationService.homeserver.value.loginMode, .password) } + func testSingleProviderOIDCState() async throws { + // Given a view model that for an app that only allows the use of a single provider that supports OIDC. + setAllowedAccountProviders(["company.com"]) + setupViewModel() + XCTAssertEqual(authenticationService.homeserver.value.loginMode, .unknown) + XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0) + + // When tapping the login button the authentication service should be used and the screen + // should request to continue the flow without any server selection needed. + let deferred = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithOIDC } + context.send(viewAction: .login) + try await deferred.fulfill() + + XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) + XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 1) + XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintReceivedArguments?.prompt, .consent) + XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintReceivedArguments?.loginHint, nil) + XCTAssertEqual(authenticationService.homeserver.value.loginMode, .oidc(supportsCreatePrompt: false)) + } + + func testSingleProviderPasswordState() async throws { + // Given a view model that for an app that only allows the use of a single provider that does not support OIDC. + setAllowedAccountProviders(["company.com"]) + setupViewModel(supportsOIDC: false) + XCTAssertEqual(authenticationService.homeserver.value.loginMode, .unknown) + XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0) + + // When tapping the login button the authentication service should be used and the screen + // should request to continue the flow without any server selection needed. + let deferred = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithPassword } + context.send(viewAction: .login) + try await deferred.fulfill() + + // Then a call to configure service should be made. + XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1) + XCTAssertEqual(authenticationService.homeserver.value.loginMode, .password) + } + // MARK: - Helpers private func setupViewModel(provisioningParameters: AccountProvisioningParameters? = nil, supportsOIDC: Bool = true) { @@ -106,6 +154,28 @@ class AuthenticationStartScreenViewModelTests: XCTestCase { // Add a fake window in order for the OIDC flow to continue viewModel.context.send(viewAction: .updateWindow(UIWindow())) } + + private func setAllowedAccountProviders(_ providers: [String]) { + let appSettings: AppSettings! = ServiceLocator.shared.settings + appSettings.override(accountProviders: providers, + allowOtherAccountProviders: false, + pushGatewayBaseURL: appSettings.pushGatewayBaseURL, + oidcRedirectURL: appSettings.oidcRedirectURL, + websiteURL: appSettings.websiteURL, + logoURL: appSettings.logoURL, + copyrightURL: appSettings.copyrightURL, + acceptableUseURL: appSettings.acceptableUseURL, + privacyURL: appSettings.privacyURL, + encryptionURL: appSettings.encryptionURL, + deviceVerificationURL: appSettings.deviceVerificationURL, + chatBackupDetailsURL: appSettings.chatBackupDetailsURL, + identityPinningViolationDetailsURL: appSettings.identityPinningViolationDetailsURL, + elementWebHosts: appSettings.elementWebHosts, + accountProvisioningHost: appSettings.accountProvisioningHost, + bugReportApplicationID: appSettings.bugReportApplicationID, + analyticsTermsURL: appSettings.analyticsTermsURL, + mapTilerConfiguration: appSettings.mapTilerConfiguration) + } } extension AuthenticationStartScreenViewModelAction { diff --git a/UnitTests/Sources/QRCodeLoginScreenViewModelTests.swift b/UnitTests/Sources/QRCodeLoginScreenViewModelTests.swift index 0d01eb447..440a26f2c 100644 --- a/UnitTests/Sources/QRCodeLoginScreenViewModelTests.swift +++ b/UnitTests/Sources/QRCodeLoginScreenViewModelTests.swift @@ -29,6 +29,7 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase { qrServiceMock.underlyingQrLoginProgressPublisher = qrProgressSubject.eraseToAnyPublisher() appMediatorMock = AppMediatorMock.default viewModel = QRCodeLoginScreenViewModel(qrCodeLoginService: qrServiceMock, + canSignInManually: true, appMediator: appMediatorMock) }