diff --git a/Sources/StytchUI/StytchB2BUIClient/ViewControllers/MFAEnrollmentSelectionViewController/MFAEnrollmentSelectionTableViewCell.swift b/Sources/StytchUI/Shared/SelectionViewController/SelectionTableViewCell.swift similarity index 85% rename from Sources/StytchUI/StytchB2BUIClient/ViewControllers/MFAEnrollmentSelectionViewController/MFAEnrollmentSelectionTableViewCell.swift rename to Sources/StytchUI/Shared/SelectionViewController/SelectionTableViewCell.swift index 45d9ecbced..7bbf1472b7 100644 --- a/Sources/StytchUI/StytchB2BUIClient/ViewControllers/MFAEnrollmentSelectionViewController/MFAEnrollmentSelectionTableViewCell.swift +++ b/Sources/StytchUI/Shared/SelectionViewController/SelectionTableViewCell.swift @@ -1,7 +1,7 @@ import StytchCore import UIKit -class MFAEnrollmentSelectionTableViewCell: UITableViewCell { +class SelectionTableViewCell: UITableViewCell { private let descriptionLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false @@ -47,11 +47,7 @@ class MFAEnrollmentSelectionTableViewCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } - func configure(with mfaMethod: StytchB2BClient.MfaMethod, image _: UIImage?) { - if mfaMethod == .sms { - descriptionLabel.text = "Text me a code" - } else { - descriptionLabel.text = "Use an authenticator app" - } + func configure(with label: String) { + descriptionLabel.text = label } } diff --git a/Sources/StytchUI/StytchB2BUIClient/ViewControllers/MFAEnrollmentSelectionViewController/MFAMethodSelectionViewController.swift b/Sources/StytchUI/Shared/SelectionViewController/SelectionViewController.swift similarity index 65% rename from Sources/StytchUI/StytchB2BUIClient/ViewControllers/MFAEnrollmentSelectionViewController/MFAMethodSelectionViewController.swift rename to Sources/StytchUI/Shared/SelectionViewController/SelectionViewController.swift index 38e3b35d95..fed7812456 100644 --- a/Sources/StytchUI/StytchB2BUIClient/ViewControllers/MFAEnrollmentSelectionViewController/MFAMethodSelectionViewController.swift +++ b/Sources/StytchUI/Shared/SelectionViewController/SelectionViewController.swift @@ -1,17 +1,17 @@ import StytchCore import UIKit -protocol MFAMethodSelectionViewControllerDelegate: AnyObject { - func didSelectMFAMethod(mfaMethod: StytchB2BClient.MfaMethod) +protocol SelectionViewControllerDelegate: AnyObject { + func didSelectCell(label: String) } -class MFAMethodSelectionViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { +class SelectionViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { private let tableView = UITableView() - private let mfaMethods: [StytchB2BClient.MfaMethod] - weak var delegate: MFAMethodSelectionViewControllerDelegate? + private let labels: [String] + weak var delegate: SelectionViewControllerDelegate? - init(mfaMethods: [StytchB2BClient.MfaMethod]) { - self.mfaMethods = mfaMethods + init(labels: [String]) { + self.labels = labels super.init(nibName: nil, bundle: nil) } @@ -35,7 +35,7 @@ class MFAMethodSelectionViewController: UIViewController, UITableViewDataSource, tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) - tableView.register(MFAEnrollmentSelectionTableViewCell.self, forCellReuseIdentifier: "MFAEnrollmentSelectionTableViewCell") + tableView.register(SelectionTableViewCell.self, forCellReuseIdentifier: "SelectionTableViewCell") tableView.dataSource = self tableView.delegate = self @@ -45,19 +45,17 @@ class MFAMethodSelectionViewController: UIViewController, UITableViewDataSource, } func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { - mfaMethods.count + labels.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell(withIdentifier: "MFAEnrollmentSelectionTableViewCell", for: indexPath) as? MFAEnrollmentSelectionTableViewCell else { + guard let cell = tableView.dequeueReusableCell(withIdentifier: "SelectionTableViewCell", for: indexPath) as? SelectionTableViewCell else { return UITableViewCell() } cell.separatorInset = UIEdgeInsets.zero cell.layoutMargins = UIEdgeInsets.zero - - let mfaMethod = mfaMethods[indexPath.row] - cell.configure(with: mfaMethod, image: nil) + cell.configure(with: labels[indexPath.row]) return cell } @@ -66,8 +64,7 @@ class MFAMethodSelectionViewController: UIViewController, UITableViewDataSource, } func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) { - let mfaMethod = mfaMethods[indexPath.row] - delegate?.didSelectMFAMethod(mfaMethod: mfaMethod) + delegate?.didSelectCell(label: labels[indexPath.row]) tableView.deselectRow(at: indexPath, animated: true) } } diff --git a/Sources/StytchUI/StytchB2BUIClient/Shared/AuthenticationOperations.swift b/Sources/StytchUI/StytchB2BUIClient/Shared/AuthenticationOperations.swift index 49aab841b8..70337aba14 100644 --- a/Sources/StytchUI/StytchB2BUIClient/Shared/AuthenticationOperations.swift +++ b/Sources/StytchUI/StytchB2BUIClient/Shared/AuthenticationOperations.swift @@ -48,6 +48,51 @@ struct AuthenticationOperations { _ = try await StytchB2BClient.magicLinks.email.loginOrSignup(parameters: parameters) } + static func sendEmailMagicLinkForAuthFlowType(configuration: StytchB2BUIClient.Configuration, emailAddress: String) async throws { + if configuration.computedAuthFlowType == .discovery { + let parameters = StytchB2BClient.MagicLinks.Email.DiscoveryParameters( + emailAddress: emailAddress, + discoveryRedirectUrl: configuration.redirectUrl + ) + _ = try await StytchB2BClient.magicLinks.email.discoverySend(parameters: parameters) + } else { + guard let organizationId = OrganizationManager.organizationId else { + throw StytchSDKError.noOrganziationId + } + + try await sendEmailMagicLink( + configuration: configuration, + emailAddress: emailAddress, + organizationId: organizationId, + redirectUrl: configuration.redirectUrl + ) + } + } + + static func sendEmailOTPForAuthFlowType(configuration: StytchB2BUIClient.Configuration, emailAddress: String) async throws { + if configuration.computedAuthFlowType == .discovery { + let parameters = StytchB2BClient.OTP.Email.Discovery.SendParameters( + emailAddress: emailAddress, + loginTemplateId: configuration.emailOtpOptions?.loginTemplateId, + locale: nil + ) + _ = try await StytchB2BClient.otp.email.discovery.send(parameters: parameters) + } else { + guard let organizationId = OrganizationManager.organizationId else { + throw StytchSDKError.noOrganziationId + } + + let parameters = StytchB2BClient.OTP.Email.LoginOrSignupParameters( + organizationId: organizationId, + emailAddress: emailAddress, + loginTemplateId: configuration.emailOtpOptions?.loginTemplateId, + signupTemplateId: configuration.emailOtpOptions?.signupTemplateId, + locale: nil + ) + _ = try await StytchB2BClient.otp.email.loginOrSignup(parameters: parameters) + } + } + static func smsSend(phoneNumberE164: String) async throws { guard let organizationId = OrganizationManager.organizationId else { throw StytchSDKError.noOrganziationId diff --git a/Sources/StytchUI/StytchB2BUIClient/Shared/BaseViewController/BaseViewController+Discovery.swift b/Sources/StytchUI/StytchB2BUIClient/Shared/BaseViewController/BaseViewController+Discovery.swift index 686445412a..c96eef3723 100644 --- a/Sources/StytchUI/StytchB2BUIClient/Shared/BaseViewController/BaseViewController+Discovery.swift +++ b/Sources/StytchUI/StytchB2BUIClient/Shared/BaseViewController/BaseViewController+Discovery.swift @@ -2,13 +2,16 @@ import StytchCore import UIKit extension BaseViewController { - func startDiscoveryFlowIfNeeded(configuration: StytchB2BUIClient.Configuration) { + func startDiscoveryFlowIfNeeded(configuration: StytchB2BUIClient.Configuration, shouldPopToRoot: Bool = true) { Task { @MainActor in let discoveredOrganizations = DiscoveryManager.discoveredOrganizations if let singleDiscoveredOrganization = discoveredOrganizations.shouldAllowDirectLoginToOrganization(configuration.directLoginForSingleMembershipOptions) { selectDiscoveredOrganization(configuration: configuration, discoveredOrganization: singleDiscoveredOrganization) } else { - navigationController?.popToRootViewController(animated: false) + if shouldPopToRoot == true { + navigationController?.popToRootViewController(animated: false) + } + let viewController: UIViewController if DiscoveryManager.discoveredOrganizations.isEmpty { if configuration.allowCreateOrganization == true, StytchB2BClient.createOrganizationEnabled == true { diff --git a/Sources/StytchUI/StytchB2BUIClient/StytchB2BUIClient/StytchB2BUIClient+Configuration.swift b/Sources/StytchUI/StytchB2BUIClient/StytchB2BUIClient/StytchB2BUIClient+Configuration.swift index f168368e5c..d7074fba31 100644 --- a/Sources/StytchUI/StytchB2BUIClient/StytchB2BUIClient/StytchB2BUIClient+Configuration.swift +++ b/Sources/StytchUI/StytchB2BUIClient/StytchB2BUIClient/StytchB2BUIClient+Configuration.swift @@ -33,10 +33,6 @@ public extension StytchB2BUIClient { products.contains(.emailMagicLinks) } - public var supportsEmailMagicLinksWithoutPasswords: Bool { - supportsEmailMagicLinks && !supportsPasswords - } - public var supportsEmailOTP: Bool { products.contains(.emailOtp) } @@ -49,16 +45,28 @@ public extension StytchB2BUIClient { products.contains(.passwords) } - public var supportsPasswordsWithoutEmailMagiclinks: Bool { - !supportsEmailMagicLinks && supportsPasswords - } - public var supportsOauth: Bool { products.contains(.oauth) && !oauthProviders.isEmpty } - public var supportsEmailMagicLinksAndPasswords: Bool { - supportsEmailMagicLinks && supportsPasswords + public var supportsEmail: Bool { + supportsEmailMagicLinks || supportsEmailOTP + } + + public var supportsEmailMagicLinksAndEmailOTP: Bool { + supportsEmailMagicLinks && supportsEmailOTP + } + + public var supportsEmailAndPasswords: Bool { + supportsEmail && supportsPasswords + } + + public var supportsEmailWithoutPasswords: Bool { + supportsEmail && !supportsPasswords + } + + public var supportsPasswordsWithoutEmail: Bool { + !supportsEmail && supportsPasswords } public var organizationSlug: String? { diff --git a/Sources/StytchUI/StytchB2BUIClient/StytchB2BUIClient/StytchB2BUIClient+ProductOrdering.swift b/Sources/StytchUI/StytchB2BUIClient/StytchB2BUIClient/StytchB2BUIClient+ProductOrdering.swift index d8c8985ee2..8539967d84 100644 --- a/Sources/StytchUI/StytchB2BUIClient/StytchB2BUIClient/StytchB2BUIClient+ProductOrdering.swift +++ b/Sources/StytchUI/StytchB2BUIClient/StytchB2BUIClient/StytchB2BUIClient+ProductOrdering.swift @@ -29,25 +29,27 @@ extension StytchB2BUIClient { var productComponents = [ProductComponent]() for product in validProducts { switch product { - case .emailMagicLinks: + case .emailMagicLinks, .emailOtp: if case .discovery = configuration.computedAuthFlowType { - productComponents.append(.emailMagicLink) - } else if configuration.supportsEmailMagicLinksAndPasswords == true, productComponents.contains(.emailMagicLinkAndPasswords) == false { - productComponents.append(.emailMagicLinkAndPasswords) - } else { - productComponents.append(.emailMagicLink) + if productComponents.contains(.email) == false { + productComponents.append(.email) + } + } else if configuration.supportsEmailAndPasswords == true { + if productComponents.contains(.emailAndPasswords) == false { + productComponents.append(.emailAndPasswords) + } + } else if productComponents.contains(.email) == false { + productComponents.append(.email) } - case .emailOtp: - break case .sso: if case .organization = configuration.computedAuthFlowType, hasSSOActiveConnections == true { productComponents.append(.ssoButtons) } case .passwords: if case .organization = configuration.computedAuthFlowType { - if configuration.supportsEmailMagicLinksAndPasswords == true, productComponents.contains(.emailMagicLinkAndPasswords) == false { - productComponents.append(.emailMagicLinkAndPasswords) - } else if configuration.supportsPasswordsWithoutEmailMagiclinks { + if configuration.supportsEmailAndPasswords == true, productComponents.contains(.emailAndPasswords) == false { + productComponents.append(.emailAndPasswords) + } else if configuration.supportsPasswordsWithoutEmail { productComponents.append(.password) } } @@ -58,7 +60,7 @@ extension StytchB2BUIClient { // If we have both buttons and input, we want to display a divider between the last 2 elements let hasButtons = productComponents.contains(.oAuthButtons) || productComponents.contains(.ssoButtons) - let showDivider = hasButtons && (configuration.supportsEmailMagicLinks || configuration.supportsPasswords) + let showDivider = hasButtons && (configuration.supportsEmail || configuration.supportsPasswords) if productComponents.count > 1, showDivider { productComponents.insert(.divider, at: productComponents.count - 1) @@ -70,8 +72,8 @@ extension StytchB2BUIClient { extension StytchB2BUIClient { enum ProductComponent: String { - case emailMagicLink - case emailMagicLinkAndPasswords + case email + case emailAndPasswords case password case oAuthButtons case ssoButtons diff --git a/Sources/StytchUI/StytchB2BUIClient/ViewControllers/B2BAuthHomeViewController/B2BAuthHomeViewController.swift b/Sources/StytchUI/StytchB2BUIClient/ViewControllers/B2BAuthHomeViewController/B2BAuthHomeViewController.swift index dce77b230f..46e73256ee 100644 --- a/Sources/StytchUI/StytchB2BUIClient/ViewControllers/B2BAuthHomeViewController/B2BAuthHomeViewController.swift +++ b/Sources/StytchUI/StytchB2BUIClient/ViewControllers/B2BAuthHomeViewController/B2BAuthHomeViewController.swift @@ -87,10 +87,10 @@ final class B2BAuthHomeViewController: BaseViewController Void - ) { - MemberManager.updateMemberEmailAddress(emailAddress) - StytchB2BUIClient.startLoading() - Task { - do { - if state.configuration.computedAuthFlowType == .discovery { - let parameters = StytchB2BClient.MagicLinks.Email.DiscoveryParameters( - emailAddress: emailAddress, - discoveryRedirectUrl: state.configuration.redirectUrl - ) - _ = try await StytchB2BClient.magicLinks.email.discoverySend(parameters: parameters) - } else { - guard let organizationId = OrganizationManager.organizationId else { - completion(StytchSDKError.noOrganziationId) - return - } - - try await AuthenticationOperations.sendEmailMagicLink( - configuration: state.configuration, - emailAddress: emailAddress, - organizationId: organizationId, - redirectUrl: state.configuration.redirectUrl - ) - } - completion(nil) - StytchB2BUIClient.stopLoading() - } catch { - completion(error) - StytchB2BUIClient.stopLoading() - } - } - } -} - -struct B2BEmailMagicLinksState { - let configuration: StytchB2BUIClient.Configuration -} diff --git a/Sources/StytchUI/StytchB2BUIClient/ViewControllers/B2BAuthHomeViewController/B2BEmailMagicLinksViewController/B2BEmailMagicLinksViewController.swift b/Sources/StytchUI/StytchB2BUIClient/ViewControllers/B2BAuthHomeViewController/B2BEmailViewController/B2BEmailViewController.swift similarity index 71% rename from Sources/StytchUI/StytchB2BUIClient/ViewControllers/B2BAuthHomeViewController/B2BEmailMagicLinksViewController/B2BEmailMagicLinksViewController.swift rename to Sources/StytchUI/StytchB2BUIClient/ViewControllers/B2BAuthHomeViewController/B2BEmailViewController/B2BEmailViewController.swift index 50fd6b2c30..01c3a95907 100644 --- a/Sources/StytchUI/StytchB2BUIClient/ViewControllers/B2BAuthHomeViewController/B2BEmailMagicLinksViewController/B2BEmailMagicLinksViewController.swift +++ b/Sources/StytchUI/StytchB2BUIClient/ViewControllers/B2BAuthHomeViewController/B2BEmailViewController/B2BEmailViewController.swift @@ -2,13 +2,15 @@ import AuthenticationServices import StytchCore import UIKit -protocol B2BEmailMagicLinksViewControllerDelegate: AnyObject { +protocol B2BEmailViewControllerDelegate: AnyObject { func emailMagicLinkSent() + func emailOTPSent() + func showEmailMethodSelection() func usePasswordInstead() } -final class B2BEmailMagicLinksViewController: BaseViewController { - weak var delegate: B2BEmailMagicLinksViewControllerDelegate? +final class B2BEmailViewController: BaseViewController { + weak var delegate: B2BEmailViewControllerDelegate? let showsUsePasswordButton: Bool private lazy var emailInput: EmailInput = .init() @@ -29,10 +31,10 @@ final class B2BEmailMagicLinksViewController: BaseViewController Void + ) { + StytchB2BUIClient.startLoading() + Task { + do { + try await AuthenticationOperations.sendEmailMagicLinkForAuthFlowType(configuration: state.configuration, emailAddress: emailAddress) + completion(nil) + StytchB2BUIClient.stopLoading() + } catch { + completion(error) + StytchB2BUIClient.stopLoading() + } + } + } + + func sendEmailOTP( + emailAddress: String, + completion: @escaping (Error?) -> Void + ) { + StytchB2BUIClient.startLoading() + Task { + do { + try await AuthenticationOperations.sendEmailOTPForAuthFlowType(configuration: state.configuration, emailAddress: emailAddress) + completion(nil) + StytchB2BUIClient.stopLoading() + } catch { + completion(error) + StytchB2BUIClient.stopLoading() + } + } + } +} + +struct B2BEmailState { + let configuration: StytchB2BUIClient.Configuration +} diff --git a/Sources/StytchUI/StytchB2BUIClient/ViewControllers/EmailMethodSelectionViewController/EmailMethodSelectionViewController.swift b/Sources/StytchUI/StytchB2BUIClient/ViewControllers/EmailMethodSelectionViewController/EmailMethodSelectionViewController.swift new file mode 100644 index 0000000000..0463eed76e --- /dev/null +++ b/Sources/StytchUI/StytchB2BUIClient/ViewControllers/EmailMethodSelectionViewController/EmailMethodSelectionViewController.swift @@ -0,0 +1,79 @@ +import AuthenticationServices +import StytchCore +import UIKit + +final class EmailMethodSelectionViewController: BaseViewController { + let emailMagicLinkLabelText = "Email me a log in link" + let emailOTPLabelText = "Email me a log in code" + + private let titleLabel: UILabel = .makeTitleLabel( + text: NSLocalizedString("stytchEmailMethodTitle", value: "Select how you'd like to continue.", comment: "") + ) + + init(state: EmailMethodSelectionState) { + super.init(viewModel: EmailMethodSelectionViewModel(state: state)) + } + + override func configureView() { + super.configureView() + + stackView.spacing = .spacingRegular + + stackView.addArrangedSubview(titleLabel) + + let emailMethodSelectionViewController = SelectionViewController(labels: [emailMagicLinkLabelText, emailOTPLabelText]) + emailMethodSelectionViewController.delegate = self + addChild(emailMethodSelectionViewController) + stackView.addArrangedSubview(emailMethodSelectionViewController.view) + emailMethodSelectionViewController.didMove(toParent: self) + + attachStackView(within: view) + + NSLayoutConstraint.activate( + stackView.arrangedSubviews.map { $0.widthAnchor.constraint(equalTo: stackView.widthAnchor) } + ) + + view.backgroundColor = .background + } + + func sendEmailMagicLink() { + viewModel.sendEmailMagicLink(emailAddress: MemberManager.emailAddress ?? "") { [weak self] error in + if let error { + self?.presentErrorAlert(error: error) + } else { + self?.emailMagicLinkSent() + } + } + } + + func sendEmailOTP() { + viewModel.sendEmailOTP(emailAddress: MemberManager.emailAddress ?? "") { [weak self] error in + if let error { + self?.presentErrorAlert(error: error) + } else { + self?.emailOTPSent() + } + } + } + + func emailOTPSent() { + Task { @MainActor in + let emailOTPEntryViewController = EmailOTPEntryViewController(state: .init(configuration: viewModel.state.configuration, didSendCode: true)) + navigationController?.pushViewController(emailOTPEntryViewController, animated: true) + } + } + + func emailMagicLinkSent() { + showEmailConfirmation(configuration: viewModel.state.configuration, type: .emailConfirmation) + } +} + +extension EmailMethodSelectionViewController: SelectionViewControllerDelegate { + func didSelectCell(label: String) { + if label == emailMagicLinkLabelText { + sendEmailMagicLink() + } else if label == emailOTPLabelText { + sendEmailOTP() + } + } +} diff --git a/Sources/StytchUI/StytchB2BUIClient/ViewControllers/EmailMethodSelectionViewController/EmailMethodSelectionViewModel.swift b/Sources/StytchUI/StytchB2BUIClient/ViewControllers/EmailMethodSelectionViewController/EmailMethodSelectionViewModel.swift new file mode 100644 index 0000000000..781eac41ed --- /dev/null +++ b/Sources/StytchUI/StytchB2BUIClient/ViewControllers/EmailMethodSelectionViewController/EmailMethodSelectionViewModel.swift @@ -0,0 +1,49 @@ +import StytchCore + +final class EmailMethodSelectionViewModel { + let state: EmailMethodSelectionState + + init( + state: EmailMethodSelectionState + ) { + self.state = state + } + + func sendEmailMagicLink( + emailAddress: String, + completion: @escaping (Error?) -> Void + ) { + StytchB2BUIClient.startLoading() + Task { + do { + try await AuthenticationOperations.sendEmailMagicLinkForAuthFlowType(configuration: state.configuration, emailAddress: emailAddress) + completion(nil) + StytchB2BUIClient.stopLoading() + } catch { + completion(error) + StytchB2BUIClient.stopLoading() + } + } + } + + func sendEmailOTP( + emailAddress: String, + completion: @escaping (Error?) -> Void + ) { + StytchB2BUIClient.startLoading() + Task { + do { + try await AuthenticationOperations.sendEmailOTPForAuthFlowType(configuration: state.configuration, emailAddress: emailAddress) + completion(nil) + StytchB2BUIClient.stopLoading() + } catch { + completion(error) + StytchB2BUIClient.stopLoading() + } + } + } +} + +struct EmailMethodSelectionState { + let configuration: StytchB2BUIClient.Configuration +} diff --git a/Sources/StytchUI/StytchB2BUIClient/ViewControllers/EmailOTPEntryViewController/EmailOTPEntryViewController.swift b/Sources/StytchUI/StytchB2BUIClient/ViewControllers/EmailOTPEntryViewController/EmailOTPEntryViewController.swift new file mode 100644 index 0000000000..340ca34a8d --- /dev/null +++ b/Sources/StytchUI/StytchB2BUIClient/ViewControllers/EmailOTPEntryViewController/EmailOTPEntryViewController.swift @@ -0,0 +1,125 @@ +import AuthenticationServices +import StytchCore +import UIKit + +final class EmailOTPEntryViewController: BaseViewController { + let otpView = OTPCodeEntryView(frame: .zero) + + private let titleLabel: UILabel = .makeTitleLabel( + text: NSLocalizedString("stytchEmailOTPEntryTitle", value: "Enter verification code", comment: "") + ) + + var timer: Timer? + + var expirationDate = Date().addingTimeInterval(120) + + lazy var expiryButton: Button = makeExpiryButton() + + init(state: EmailOTPEntryState) { + super.init(viewModel: EmailOTPEntryViewModel(state: state)) + } + + override func configureView() { + super.configureView() + + stackView.spacing = .spacingRegular + + stackView.addArrangedSubview(titleLabel) + + let emailConfirmationLabel = UILabel.makeComboLabel( + withPlainText: "A 6-digit passcode was sent to you at", + boldText: MemberManager.emailAddress, + fontSize: 18, + alignment: .left + ) + stackView.addArrangedSubview(emailConfirmationLabel) + + otpView.delegate = self + stackView.addArrangedSubview(otpView) + + stackView.addArrangedSubview(expiryButton) + + stackView.addArrangedSubview(SpacerView()) + + attachStackViewToScrollView() + + NSLayoutConstraint.activate([ + otpView.heightAnchor.constraint(equalToConstant: 50), + ]) + + NSLayoutConstraint.activate( + stackView.arrangedSubviews.map { $0.widthAnchor.constraint(equalTo: stackView.widthAnchor) } + ) + + if viewModel.state.didSendCode == false { + expiryButton.setTitle("", for: .normal) + timer?.invalidate() + resendCode() + } else { + startTimer() + } + } + + func startTimer() { + resetExpirationDate() + timer?.invalidate() + updateExpiryText() + timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(updateExpiryText), userInfo: nil, repeats: true) + } + + func resetExpirationDate() { + expirationDate = Date().addingTimeInterval(121) + } +} + +extension EmailOTPEntryViewController: OTPEntryViewControllerProtocol { + func resendCode() { + StytchB2BUIClient.startLoading() + Task { + do { + try await AuthenticationOperations.sendEmailOTPForAuthFlowType(configuration: viewModel.state.configuration, emailAddress: MemberManager.emailAddress ?? "") + startTimer() + StytchB2BUIClient.stopLoading() + } catch { + StytchB2BUIClient.stopLoading() + presentErrorAlert(error: error) + } + } + } + + func presentCodeResetConfirmation() { + guard let emailAddress = MemberManager.emailAddress else { + return + } + + presentCodeResetConfirmation(message: .localizedStringWithFormat( + NSLocalizedString("stytch.otpNewCodeWillBeSent", value: "A new code will be sent to %@.", comment: ""), emailAddress + )) + } + + @objc private func updateExpiryText() { + updateExpirationText() + } +} + +extension EmailOTPEntryViewController: OTPCodeEntryViewDelegate { + func didEnterOTPCode(_ code: String) { + StytchB2BUIClient.startLoading() + Task { + do { + if viewModel.state.configuration.computedAuthFlowType == .discovery { + try await viewModel.emailDiscoveryAuthenticate(code: code) + startDiscoveryFlowIfNeeded(configuration: viewModel.state.configuration, shouldPopToRoot: false) + } else { + try await viewModel.emailAuthenticate(code: code) + startMFAFlowIfNeeded(configuration: viewModel.state.configuration) + } + StytchB2BUIClient.stopLoading() + } catch { + otpView.clear() + presentErrorAlert(error: error) + StytchB2BUIClient.stopLoading() + } + } + } +} diff --git a/Sources/StytchUI/StytchB2BUIClient/ViewControllers/EmailOTPEntryViewController/EmailOTPEntryViewModel.swift b/Sources/StytchUI/StytchB2BUIClient/ViewControllers/EmailOTPEntryViewController/EmailOTPEntryViewModel.swift new file mode 100644 index 0000000000..98a85ffc9a --- /dev/null +++ b/Sources/StytchUI/StytchB2BUIClient/ViewControllers/EmailOTPEntryViewController/EmailOTPEntryViewModel.swift @@ -0,0 +1,46 @@ +import Foundation +import StytchCore + +final class EmailOTPEntryViewModel { + let state: EmailOTPEntryState + + init( + state: EmailOTPEntryState + ) { + self.state = state + } + + func emailAuthenticate(code: String) async throws { + let emailAddress = MemberManager.emailAddress ?? "" + + guard let organizationId = OrganizationManager.organizationId else { + throw StytchSDKError.noOrganziationId + } + + let parameters = StytchB2BClient.OTP.Email.AuthenticateParameters( + code: code, + organizationId: organizationId, + emailAddress: emailAddress, + locale: nil, + sessionDurationMinutes: state.configuration.sessionDurationMinutes + ) + let response = try await StytchB2BClient.otp.email.authenticate(parameters: parameters) + B2BAuthenticationManager.handlePrimaryMFAReponse(b2bMFAAuthenticateResponse: response) + } + + func emailDiscoveryAuthenticate(code: String) async throws { + let emailAddress = MemberManager.emailAddress ?? "" + + let parameters = StytchB2BClient.OTP.Email.Discovery.AuthenticateParameters( + code: code, + emailAddress: emailAddress + ) + let response = try await StytchB2BClient.otp.email.discovery.authenticate(parameters: parameters) + DiscoveryManager.updateDiscoveredOrganizations(newDiscoveredOrganizations: response.discoveredOrganizations) + } +} + +struct EmailOTPEntryState { + let configuration: StytchB2BUIClient.Configuration + let didSendCode: Bool +} diff --git a/Sources/StytchUI/StytchB2BUIClient/ViewControllers/MFAEnrollmentSelectionViewController/MFAEnrollmentSelectionViewController.swift b/Sources/StytchUI/StytchB2BUIClient/ViewControllers/MFAEnrollmentSelectionViewController/MFAEnrollmentSelectionViewController.swift index 23cfdbe999..35e45aeaf6 100644 --- a/Sources/StytchUI/StytchB2BUIClient/ViewControllers/MFAEnrollmentSelectionViewController/MFAEnrollmentSelectionViewController.swift +++ b/Sources/StytchUI/StytchB2BUIClient/ViewControllers/MFAEnrollmentSelectionViewController/MFAEnrollmentSelectionViewController.swift @@ -23,8 +23,6 @@ final class MFAEnrollmentSelectionViewController: BaseViewControllerEditor CFBundleURLSchemes - stytchui-your-public-token + stytchui-public-token-test-b6be6a68-d178-4a2d-ac98-9579020905bf