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.
This commit is contained in:
@@ -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<String>
|
||||
|
||||
/// 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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,11 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
|
||||
private var scanTask: Task<Void, Never>?
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -17,6 +17,7 @@ enum UITestsScreenIdentifier: String {
|
||||
case appLockSetupFlowUnlock
|
||||
case authenticationFlow
|
||||
case provisionedAuthenticationFlow
|
||||
case singleProviderAuthenticationFlow
|
||||
case bugReport
|
||||
case createPoll
|
||||
case createRoom
|
||||
|
||||
Submodule Enterprise updated: e004345aa5...e2f589c188
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4624b04341dd9beefcf689a39b047c9d6ebea797d68996e4ea669b45d6494faf
|
||||
size 112957
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:58643fdc4bc95fda9cdc91d33b9b98973c6b67de256ca800a2babece767808d2
|
||||
size 134945
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:75e324ee678e506005f852ac377fc02281ee60335c3f996df7528335b9cc255d
|
||||
size 66133
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b5407ba8180475d70b3aee7fdcb6a2e8e84fb6977a29b1b223566c3d31872ec2
|
||||
size 93700
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4ecff6f7fb1e12a42c25761e0b83658550e62e6ec4a8fca1e239b63d5851cf31
|
||||
size 122577
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:77a3fbc2dd4fd8f6c45c205bff44b5544b4e1d368e0457f6e9ae4b17ecca9b7f
|
||||
size 132753
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:033db8a5d3b9b0cd70b8c8b01856dddf5703f11be3027fe5ac1d1c105ab8f1b6
|
||||
size 67172
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a355d8275b8be088bfab2b61a630fc164eb6ea3537ad335435df80b899615c48
|
||||
size 84715
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9f9b378ca59a6976c0a5ac40d1b670f5ed813334095f538e63cae6c539311fb0
|
||||
size 1337155
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:208e50e192c288069dd95ee88619b593b96b203a511c92539c27ddf0982a5aa4
|
||||
size 1210790
|
||||
@@ -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 {
|
||||
|
||||
@@ -29,6 +29,7 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
|
||||
qrServiceMock.underlyingQrLoginProgressPublisher = qrProgressSubject.eraseToAnyPublisher()
|
||||
appMediatorMock = AppMediatorMock.default
|
||||
viewModel = QRCodeLoginScreenViewModel(qrCodeLoginService: qrServiceMock,
|
||||
canSignInManually: true,
|
||||
appMediator: appMediatorMock)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user