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:
Doug
2025-05-20 11:09:50 +01:00
committed by GitHub
parent 10fb2b5dda
commit 390553f5fe
26 changed files with 326 additions and 111 deletions
@@ -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")