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
@@ -31,6 +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 screen used for the whole QR Code flow.
case qrCodeLoginScreen
@@ -55,12 +57,13 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
/// The flow is being started.
case start
/// Modify the flow using the provisioning parameters in the `userInfo`.
case applyProvisioningParameters
/// The user would like to login with a QR code.
case loginWithQR
/// The user would like to login without a QR code.
case loginManually
/// The user would like to register a new account.
case register
/// Show the server confirmation screen.
case confirmServer(AuthenticationFlow)
/// The user encountered a problem.
case reportProblem
@@ -74,19 +77,19 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
/// The user is no longer selecting a server.
case dismissedServerSelection
/// Show the web authentication session for OIDC.
/// Show the web authentication session for OIDC (using the parameters in the `userInfo`).
case continueWithOIDC
/// The web authentication session was aborted.
case cancelledOIDCAuthentication
/// Show the screen to login with password.
case cancelledOIDCAuthentication(previousState: State)
/// Show the screen to login with password (with the optional login hint in the `userInfo`).
case continueWithPassword
/// The password login was aborted.
case cancelledPasswordLogin
case cancelledPasswordLogin(previousState: State)
/// The user has finished reporting a problem (or viewing the logs).
case bugReportFlowComplete
case bugReportFlowComplete(previousState: State)
/// The user has successfully signed in!
/// The user has successfully signed in. The new session can be found in the `userInfo`.
case signedIn
}
@@ -128,18 +131,53 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
}
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
fatalError()
switch appRoute {
case .accountProvisioningLink(let provisioningParameters):
if stateMachine.state != .startScreen {
clearRoute(animated: animated)
}
stateMachine.tryEvent(.applyProvisioningParameters, userInfo: provisioningParameters)
default:
fatalError()
}
}
func clearRoute(animated: Bool) {
fatalError()
switch stateMachine.state {
case .initial, .startScreen, .provisionedStartScreen:
break
case .qrCodeLoginScreen:
navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.cancelledLoginWithQR) // Needs to be handled manually.
case .serverConfirmationScreen:
navigationStackCoordinator.popToRoot(animated: animated)
case .serverSelectionScreen:
navigationStackCoordinator.setSheetCoordinator(nil)
navigationStackCoordinator.popToRoot(animated: animated)
case .oidcAuthentication:
oidcPresenter?.cancel()
navigationStackCoordinator.popToRoot(animated: animated)
case .loginScreen:
navigationStackCoordinator.popToRoot(animated: animated)
case .bugReportFlow:
navigationStackCoordinator.setSheetCoordinator(nil)
case .complete:
fatalError()
}
}
// MARK: - Setup
private func configureStateMachine() {
stateMachine.addRoutes(event: .start, transitions: [.initial => .startScreen]) { [weak self] _ in
self?.showStartScreen()
self?.showStartScreen(fromState: .initial)
}
stateMachine.addRoutes(event: .applyProvisioningParameters, transitions: [.initial => .provisionedStartScreen,
.startScreen => .provisionedStartScreen]) { [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
@@ -151,10 +189,10 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
// Manual Authentication
stateMachine.addRoutes(event: .loginManually, transitions: [.startScreen => .serverConfirmationScreen]) { [weak self] _ in
stateMachine.addRoutes(event: .confirmServer(.login), transitions: [.startScreen => .serverConfirmationScreen]) { [weak self] _ in
self?.showServerConfirmationScreen(authenticationFlow: .login)
}
stateMachine.addRoutes(event: .register, transitions: [.startScreen => .serverConfirmationScreen]) { [weak self] _ in
stateMachine.addRoutes(event: .confirmServer(.register), transitions: [.startScreen => .serverConfirmationScreen]) { [weak self] _ in
self?.showServerConfirmationScreen(authenticationFlow: .register)
}
stateMachine.addRoutes(event: .cancelledServerConfirmation, transitions: [.serverConfirmationScreen => .startScreen])
@@ -167,25 +205,32 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
}
stateMachine.addRoutes(event: .dismissedServerSelection, transitions: [.serverSelectionScreen => .serverConfirmationScreen])
stateMachine.addRoutes(event: .continueWithOIDC, transitions: [.serverConfirmationScreen => .oidcAuthentication]) { [weak self] context in
stateMachine.addRoutes(event: .continueWithOIDC, transitions: [.serverConfirmationScreen => .oidcAuthentication,
.provisionedStartScreen => .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)
self?.showOIDCAuthentication(oidcData: oidcData, presentationAnchor: window, fromState: context.fromState)
}
stateMachine.addRoutes(event: .cancelledOIDCAuthentication, transitions: [.oidcAuthentication => .serverConfirmationScreen])
stateMachine.addRoutes(event: .cancelledOIDCAuthentication(previousState: .serverConfirmationScreen), transitions: [.oidcAuthentication => .serverConfirmationScreen])
stateMachine.addRoutes(event: .cancelledOIDCAuthentication(previousState: .provisionedStartScreen), transitions: [.oidcAuthentication => .provisionedStartScreen])
stateMachine.addRoutes(event: .continueWithPassword, transitions: [.serverConfirmationScreen => .loginScreen]) { [weak self] _ in
self?.showLoginScreen()
stateMachine.addRoutes(event: .continueWithPassword, transitions: [.serverConfirmationScreen => .loginScreen,
.provisionedStartScreen => .loginScreen]) { [weak self] context in
let loginHint = context.userInfo as? String
self?.showLoginScreen(loginHint: loginHint, fromState: context.fromState)
}
stateMachine.addRoutes(event: .cancelledPasswordLogin, transitions: [.loginScreen => .serverConfirmationScreen])
stateMachine.addRoutes(event: .cancelledPasswordLogin(previousState: .serverConfirmationScreen), transitions: [.loginScreen => .serverConfirmationScreen])
stateMachine.addRoutes(event: .cancelledPasswordLogin(previousState: .provisionedStartScreen), transitions: [.loginScreen => .provisionedStartScreen])
// Bug Report
stateMachine.addRoutes(event: .reportProblem, transitions: [.startScreen => .bugReportFlow]) { [weak self] _ in
self?.startBugReportFlow()
stateMachine.addRoutes(event: .reportProblem, transitions: [.startScreen => .bugReportFlow,
.provisionedStartScreen => .bugReportFlow]) { [weak self] context in
self?.startBugReportFlow(fromState: context.fromState)
}
stateMachine.addRoutes(event: .bugReportFlowComplete, transitions: [.bugReportFlow => .startScreen])
stateMachine.addRoutes(event: .bugReportFlowComplete(previousState: .startScreen), transitions: [.bugReportFlow => .startScreen])
stateMachine.addRoutes(event: .bugReportFlowComplete(previousState: .provisionedStartScreen), transitions: [.bugReportFlow => .provisionedStartScreen])
// Completion
@@ -208,9 +253,12 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func showStartScreen() {
let parameters = AuthenticationStartScreenParameters(showCreateAccountButton: appSettings.showCreateAccountButton,
isBugReportServiceEnabled: bugReportService.isEnabled)
private func showStartScreen(fromState: State, applying provisioningParameters: AccountProvisioningParameters? = nil) {
let parameters = AuthenticationStartScreenParameters(authenticationService: authenticationService,
provisioningParameters: provisioningParameters,
isBugReportServiceEnabled: bugReportService.isEnabled,
appSettings: appSettings,
userIndicatorController: userIndicatorController)
let coordinator = AuthenticationStartScreenCoordinator(parameters: parameters)
coordinator.actions
@@ -220,19 +268,26 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .loginWithQR:
stateMachine.tryEvent(.loginWithQR)
case .loginManually:
stateMachine.tryEvent(.loginManually)
case .login:
stateMachine.tryEvent(.confirmServer(.login))
case .register:
stateMachine.tryEvent(.register)
stateMachine.tryEvent(.confirmServer(.register))
case .reportProblem:
stateMachine.tryEvent(.reportProblem)
case .loginDirectlyWithOIDC(let oidcData, let window):
stateMachine.tryEvent(.continueWithOIDC, userInfo: (oidcData, window))
case .loginDirectlyWithPassword(let loginHint):
stateMachine.tryEvent(.continueWithPassword, userInfo: loginHint)
}
}
.store(in: &cancellables)
navigationStackCoordinator.setRootCoordinator(coordinator)
navigationRootCoordinator.setRootCoordinator(navigationStackCoordinator)
if fromState == .initial {
navigationRootCoordinator.setRootCoordinator(navigationStackCoordinator)
}
}
// MARK: - QR Code
@@ -249,7 +304,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
case .signInManually:
navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.cancelledLoginWithQR)
stateMachine.tryEvent(.loginManually)
stateMachine.tryEvent(.confirmServer(.login))
case .cancel:
navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.cancelledLoginWithQR)
@@ -324,7 +379,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func showOIDCAuthentication(oidcData: OIDCAuthorizationDataProxy, presentationAnchor: UIWindow) {
private func showOIDCAuthentication(oidcData: OIDCAuthorizationDataProxy, presentationAnchor: UIWindow, fromState: State) {
let presenter = OIDCAuthenticationPresenter(authenticationService: authenticationService,
oidcRedirectURL: appSettings.oidcRedirectURL,
presentationAnchor: presentationAnchor,
@@ -336,15 +391,16 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
case .success(let userSession):
stateMachine.tryEvent(.signedIn, userInfo: userSession)
case .failure:
stateMachine.tryEvent(.cancelledOIDCAuthentication)
stateMachine.tryEvent(.cancelledOIDCAuthentication(previousState: fromState))
// Nothing more to do, the alerts are handled by the presenter.
}
oidcPresenter = nil
}
}
private func showLoginScreen() {
private func showLoginScreen(loginHint: String?, fromState: State) {
let parameters = LoginScreenCoordinatorParameters(authenticationService: authenticationService,
loginHint: loginHint,
userIndicatorController: userIndicatorController,
analytics: analytics)
let coordinator = LoginScreenCoordinator(parameters: parameters)
@@ -364,13 +420,13 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
.store(in: &cancellables)
navigationStackCoordinator.push(coordinator) { [weak self] in
self?.stateMachine.tryEvent(.cancelledPasswordLogin)
self?.stateMachine.tryEvent(.cancelledPasswordLogin(previousState: fromState))
}
}
// MARK: - Bug Report
private func startBugReportFlow() {
private func startBugReportFlow(fromState: State) {
let coordinator = BugReportFlowCoordinator(parameters: .init(presentationMode: .sheet(navigationStackCoordinator),
userIndicatorController: userIndicatorController,
bugReportService: bugReportService,
@@ -378,7 +434,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
coordinator.actionsPublisher.sink { [weak self] action in
switch action {
case .complete:
self?.stateMachine.tryEvent(.bugReportFlowComplete)
self?.stateMachine.tryEvent(.bugReportFlowComplete(previousState: fromState))
}
}
.store(in: &cancellables)
@@ -77,6 +77,8 @@ class EncryptionSettingsFlowCoordinator: FlowCoordinatorProtocol {
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
switch appRoute {
case .accountProvisioningLink:
break // We always ignore this flow when logged in.
case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias,
.roomDetails, .roomMemberDetails, .userProfile,
.event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias,
@@ -214,7 +214,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
case .roomAlias, .childRoomAlias, .eventOnRoomAlias, .childEventOnRoomAlias:
break // These are converted to a room ID route one level above.
case .roomList, .userProfile, .call, .genericCallLink, .settings, .chatBackupSettings:
case .accountProvisioningLink, .roomList, .userProfile, .call, .genericCallLink, .settings, .chatBackupSettings:
break // These routes can't be handled.
}
}
@@ -214,6 +214,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
} else {
stateMachine.processEvent(.showShareExtensionRoomList(sharePayload: payload), userInfo: .init(animated: animated))
}
case .accountProvisioningLink:
break // We always ignore this flow when logged in.
}
}