Add support for Account Provisioning links. (#4108)

* Add support for account provisioning links and route them to the authentication flow.

* Use the provisioning parameters to configure the authentication flow.

* Add UI tests for the provisioned authentication flow.

* Record new preview snapshots.

* Add unit tests.

* Make the domain configurable in the app settings.

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