diff --git a/Sources/StytchCore/Environment.swift b/Sources/StytchCore/Environment.swift index c289316813..1a8cfc9dcf 100644 --- a/Sources/StytchCore/Environment.swift +++ b/Sources/StytchCore/Environment.swift @@ -63,6 +63,8 @@ struct Environment { let userStorage: UserStorage = .init() + let memberStorage: MemberStorage = .init() + var localStorage: LocalStorage = .init() var cookieClient: CookieClient = .live diff --git a/Sources/StytchCore/Generated/StytchB2BClient.Members.deleteFactor+AsyncVariants.generated.swift b/Sources/StytchCore/Generated/StytchB2BClient.Members.deleteFactor+AsyncVariants.generated.swift new file mode 100644 index 0000000000..c2ea0ed73a --- /dev/null +++ b/Sources/StytchCore/Generated/StytchB2BClient.Members.deleteFactor+AsyncVariants.generated.swift @@ -0,0 +1,33 @@ +// Generated using Sourcery 2.0.2 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT +import Combine +import Foundation + +public extension StytchB2BClient.Members { + /// Deletes, by id, an existing authentication factor associated with the current member. + func deleteFactor(_ factor: Member.AuthenticationFactor, completion: @escaping Completion) { + Task { + do { + completion(.success(try await deleteFactor(factor))) + } catch { + completion(.failure(error)) + } + } + } + + /// Deletes, by id, an existing authentication factor associated with the current member. + func deleteFactor(_ factor: Member.AuthenticationFactor) -> AnyPublisher { + return Deferred { + Future({ promise in + Task { + do { + promise(.success(try await deleteFactor(factor))) + } catch { + promise(.failure(error)) + } + } + }) + } + .eraseToAnyPublisher() + } +} diff --git a/Sources/StytchCore/Generated/StytchB2BClient.Members.update+AsyncVariants.generated.swift b/Sources/StytchCore/Generated/StytchB2BClient.Members.update+AsyncVariants.generated.swift new file mode 100644 index 0000000000..d5f7cc24af --- /dev/null +++ b/Sources/StytchCore/Generated/StytchB2BClient.Members.update+AsyncVariants.generated.swift @@ -0,0 +1,33 @@ +// Generated using Sourcery 2.0.2 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT +import Combine +import Foundation + +public extension StytchB2BClient.Members { + /// Updates the current member. + func update(parameters: UpdateParameters, completion: @escaping Completion) { + Task { + do { + completion(.success(try await update(parameters: parameters))) + } catch { + completion(.failure(error)) + } + } + } + + /// Updates the current member. + func update(parameters: UpdateParameters) -> AnyPublisher { + return Deferred { + Future({ promise in + Task { + do { + promise(.success(try await update(parameters: parameters))) + } catch { + promise(.failure(error)) + } + } + }) + } + .eraseToAnyPublisher() + } +} diff --git a/Sources/StytchCore/MemberStorage.swift b/Sources/StytchCore/MemberStorage.swift new file mode 100644 index 0000000000..64eaf0f173 --- /dev/null +++ b/Sources/StytchCore/MemberStorage.swift @@ -0,0 +1,28 @@ +import Combine +import Foundation + +final class MemberStorage { + private(set) var member: Member? { + get { localStorage.member } + set { localStorage.member = newValue } + } + + private(set) lazy var onMemberChange = _onMemberChange + .map { [weak self] in self?.member == nil } + .removeDuplicates() + .map { [weak self] _ in self?.member } + + private let _onMemberChange = PassthroughSubject() + + @Dependency(\.localStorage) private var localStorage + + func updateMember(_ member: Member) { + self.member = member + _onMemberChange.send(()) + } + + func reset() { + member = nil + _onMemberChange.send(()) + } +} diff --git a/Sources/StytchCore/StytchB2BClient/Models/Member.swift b/Sources/StytchCore/StytchB2BClient/Models/Member.swift index 45f00dba36..fd8d6c10c9 100644 --- a/Sources/StytchCore/StytchB2BClient/Models/Member.swift +++ b/Sources/StytchCore/StytchB2BClient/Models/Member.swift @@ -12,6 +12,7 @@ public struct Member: Codable { public let ssoRegistrations: [SSORegistration] public let trustedMetadata: JSON public let untrustedMetadata: JSON + public let memberPasswordId: String let memberId: ID } @@ -29,6 +30,13 @@ public extension Member { self = .init(rawValue: rawValue) ?? .unknown } } + + /// The authentication factors which are able to be managed via member-management calls. + enum AuthenticationFactor { + case totp + case phoneNumber + case password(passwordId: String) + } } /// A type representing a specific SSO registration. diff --git a/Sources/StytchCore/StytchB2BClient/StytchB2BClient+Members.swift b/Sources/StytchCore/StytchB2BClient/StytchB2BClient+Members.swift index 3c51efe985..1b9a90fe8e 100644 --- a/Sources/StytchCore/StytchB2BClient/StytchB2BClient+Members.swift +++ b/Sources/StytchCore/StytchB2BClient/StytchB2BClient+Members.swift @@ -1,9 +1,22 @@ +import Combine + +public extension StytchB2BClient { + /// The interface for interacting with member products. + static var member: Members { .init(router: organization.router.scopedRouter { $0.members }) } +} + public extension StytchB2BClient { /// The SDK allows you to view the current member's information, such as fetching (or viewing the most recent cached version) of the current member. struct Members { let router: NetworkingRouter @Dependency(\.localStorage) private var localStorage + @Dependency(\.memberStorage) private var memberStorage + + /// A publisher which emits following a change in member status and returns either the member object or nil. You can use this as an indicator to set up or tear down your UI accordingly. + public var onMemberChange: AnyPublisher { + memberStorage.onMemberChange.eraseToAnyPublisher() + } /// Returns the most-recent cached copy of the member object, if it has already been fetched via another method, else nil. public func getSync() -> Member? { @@ -17,12 +30,37 @@ public extension StytchB2BClient { localStorage.member = response.member return response } - } -} -public extension StytchB2BClient { - /// The interface for interacting with member products. - static var member: Members { .init(router: organization.router.scopedRouter { $0.members }) } + // sourcery: AsyncVariants + /// Updates the current member. + public func update(parameters: UpdateParameters) async throws -> MemberResponse { + try await updatingCachedMember { + try await router.put(to: .update, parameters: parameters) + } + } + + // sourcery: AsyncVariants + /// Deletes, by id, an existing authentication factor associated with the current member. + public func deleteFactor(_ factor: Member.AuthenticationFactor) async throws -> MemberResponse { + let response: MemberResponse + switch factor { + case .totp: + response = try await router.delete(route: .deleteTOTP) + case .phoneNumber: + response = try await router.delete(route: .deletePhoneNumber) + case let .password(passwordId): + response = try await router.delete(route: .deletePassword(passwordId: passwordId)) + } + memberStorage.updateMember(response.member) + return response + } + + private func updatingCachedMember(_ performRequest: () async throws -> MemberResponse) async rethrows -> MemberResponse { + let response = try await performRequest() + memberStorage.updateMember(response.wrapped.member) + return response + } + } } public extension StytchB2BClient.Members { @@ -31,7 +69,38 @@ public extension StytchB2BClient.Members { /// The underlying data for the MemberResponse type. struct MemberResponseData: Codable { + /// The current member's member id. + public let memberId: String? + /// The current member. public let member: Member + + /// The current member's organization. + public let organization: Organization? + } +} + +public extension StytchB2BClient.Members { + /// The dedicated parameters type for the update member call. + struct UpdateParameters: Codable { + let name: String? + let untrustedMetadata: JSON? + let mfaEnrolled: Bool? + let mfaPhoneNumber: String? + let defaultMfaMethod: String? + + public init( + name: String? = nil, + untrustedMetadata: JSON? = nil, + mfaEnrolled: Bool? = nil, + mfaPhoneNumber: String? = nil, + defaultMfaMethod: String? = nil + ) { + self.name = name + self.untrustedMetadata = untrustedMetadata + self.mfaEnrolled = mfaEnrolled + self.mfaPhoneNumber = mfaPhoneNumber + self.defaultMfaMethod = defaultMfaMethod + } } } diff --git a/Sources/StytchCore/StytchB2BClient/StytchB2BClient+Routes.swift b/Sources/StytchCore/StytchB2BClient/StytchB2BClient+Routes.swift index 1359f9d3ff..65bec256d0 100644 --- a/Sources/StytchCore/StytchB2BClient/StytchB2BClient+Routes.swift +++ b/Sources/StytchCore/StytchB2BClient/StytchB2BClient+Routes.swift @@ -111,12 +111,27 @@ extension StytchB2BClient { } } - enum MembersRoute: String, RouteType { + enum MembersRoute: RouteType { // swiftlint:disable:next identifier_name case me + case update + case deletePhoneNumber + case deleteTOTP + case deletePassword(passwordId: String) var path: Path { - .init(rawValue: rawValue) + switch self { + case .me: + return Path(rawValue: "me") + case .update: + return Path(rawValue: "update") + case .deletePhoneNumber: + return Path(rawValue: "deletePhoneNumber") + case .deleteTOTP: + return Path(rawValue: "deleteTOTP") + case let .deletePassword(passwordId): + return Path(rawValue: "passwords/\(passwordId)") + } } } } diff --git a/StytchDemo/B2BWorkbench/AuthMethodControllers/MemberViewController.swift b/StytchDemo/B2BWorkbench/AuthMethodControllers/MemberViewController.swift index 8942bf4fef..314fc2ef5f 100644 --- a/StytchDemo/B2BWorkbench/AuthMethodControllers/MemberViewController.swift +++ b/StytchDemo/B2BWorkbench/AuthMethodControllers/MemberViewController.swift @@ -1,7 +1,10 @@ +import Combine import StytchCore import UIKit final class MemberViewController: UIViewController { + private var cancellable: AnyCancellable? + private let stackView: UIStackView = { let view = UIStackView() view.layoutMargins = Constants.insets @@ -23,6 +26,40 @@ final class MemberViewController: UIViewController { return .init(configuration: configuration, primaryAction: getSyncAction) }() + private lazy var nameTextField: UITextField = { + let textField: UITextField = .init(frame: .zero, primaryAction: updateAction) + textField.borderStyle = .roundedRect + textField.placeholder = "Name" + textField.autocorrectionType = .no + textField.autocapitalizationType = .none + textField.keyboardType = .default + return textField + }() + + private lazy var updateButton: UIButton = { + var configuration: UIButton.Configuration = .borderedProminent() + configuration.title = "Update" + return .init(configuration: configuration, primaryAction: updateAction) + }() + + private lazy var deletePasswordButton: UIButton = { + var configuration: UIButton.Configuration = .borderedProminent() + configuration.title = "Delete Password" + return .init(configuration: configuration, primaryAction: deletePasswordAction) + }() + + private lazy var deletePhoneNumberButton: UIButton = { + var configuration: UIButton.Configuration = .borderedProminent() + configuration.title = "Delete Phone Number" + return .init(configuration: configuration, primaryAction: deletePhoneNumberAction) + }() + + private lazy var deleteTOTPButton: UIButton = { + var configuration: UIButton.Configuration = .borderedProminent() + configuration.title = "Delete TOTP" + return .init(configuration: configuration, primaryAction: deleteTOTPAction) + }() + private lazy var getAction: UIAction = .init { _ in Task { do { @@ -35,7 +72,64 @@ final class MemberViewController: UIViewController { } private lazy var getSyncAction: UIAction = .init { _ in - print(StytchB2BClient.member.getSync()) + if let member = StytchB2BClient.member.getSync() { + print("getSync member: \(member.name)") + } else { + print("getSync member is nil") + } + } + + private lazy var updateAction: UIAction = .init { _ in + self.update() + } + + private lazy var deletePasswordAction: UIAction = .init { _ in + + guard let passwordId = StytchB2BClient.member.getSync()?.memberPasswordId else { + return + } + + Task { + do { + let response = try await StytchB2BClient.member.deleteFactor(.password(passwordId: passwordId)) + } catch { + print("delete password error \(error.errorInfo)") + } + } + } + + private lazy var deletePhoneNumberAction: UIAction = .init { _ in + Task { + do { + let response = try await StytchB2BClient.member.deleteFactor(.phoneNumber) + } catch { + print("delete phone number error \(error.errorInfo)") + } + } + } + + private lazy var deleteTOTPAction: UIAction = .init { _ in + Task { + do { + let response = try await StytchB2BClient.member.deleteFactor(.totp) + } catch { + print("delete totp error \(error.errorInfo)") + } + } + } + + private func update() { + guard let name = nameTextField.text, !name.isEmpty else { return } + + Task { + do { + let parameters = StytchB2BClient.Members.UpdateParameters(name: name) + let response = try await StytchB2BClient.member.update(parameters: parameters) + presentAlertWithTitle(alertTitle: "Member Updated!") + } catch { + presentErrorWithDescription(error: error, description: "update member") + } + } } override func viewDidLoad() { @@ -55,5 +149,21 @@ final class MemberViewController: UIViewController { stackView.addArrangedSubview(getButton) stackView.addArrangedSubview(getSyncButton) + stackView.addArrangedSubview(nameTextField) + stackView.addArrangedSubview(updateButton) + stackView.addArrangedSubview(deletePasswordButton) + stackView.addArrangedSubview(deletePhoneNumberButton) + stackView.addArrangedSubview(deleteTOTPButton) + + setUpMemberChangeListener() + } + + func setUpMemberChangeListener() { + cancellable = StytchB2BClient.member.onMemberChange + .compactMap { $0 } + .receive(on: DispatchQueue.main) + .sink { member in + print("MemberChangeListener Updated Member: \(member.name)") + } } } diff --git a/Tests/StytchCoreTests/B2BMagicLinksTestCase.swift b/Tests/StytchCoreTests/B2BMagicLinksTestCase.swift index 971686f17b..ff2331c859 100644 --- a/Tests/StytchCoreTests/B2BMagicLinksTestCase.swift +++ b/Tests/StytchCoreTests/B2BMagicLinksTestCase.swift @@ -179,6 +179,7 @@ extension Member { ssoRegistrations: [], trustedMetadata: [:], untrustedMetadata: [:], + memberPasswordId: "", memberId: "member_1234" ) } diff --git a/Tests/StytchCoreTests/B2BMembersTestCase.swift b/Tests/StytchCoreTests/B2BMembersTestCase.swift index 2fdafbcbc2..27699c7656 100644 --- a/Tests/StytchCoreTests/B2BMembersTestCase.swift +++ b/Tests/StytchCoreTests/B2BMembersTestCase.swift @@ -1,7 +1,29 @@ import XCTest @testable import StytchCore +// swiftlint:disable implicitly_unwrapped_optional final class B2BMembersTestCase: BaseTestCase { + var response: StytchB2BClient.Members.MemberResponse! + + override func setUpWithError() throws { + try super.setUpWithError() + + response = StytchB2BClient.Members.MemberResponse( + requestId: "123", + statusCode: 200, + wrapped: .init( + memberId: "member_1234", + member: .mock, + organization: nil + ) + ) + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + response = nil + } + func testSync() throws { XCTAssertNil(StytchB2BClient.member.getSync()) Current.localStorage.member = .mock @@ -9,11 +31,78 @@ final class B2BMembersTestCase: BaseTestCase { } func testGet() async throws { - networkInterceptor.responses { StytchB2BClient.Members.MemberResponse(requestId: "123", statusCode: 200, wrapped: .init(member: .mock)) } + networkInterceptor.responses { + response + } XCTAssertNil(StytchB2BClient.member.getSync()) let getMemberResponse = try await StytchB2BClient.member.get() XCTAssertNotNil(StytchB2BClient.member.getSync()) XCTAssertEqual(getMemberResponse.member.id, StytchB2BClient.member.getSync()?.id) - try XCTAssertRequest(networkInterceptor.requests[0], urlString: "https://web.stytch.com/sdk/v1/b2b/organizations/members/me", method: .get) + + try XCTAssertRequest( + networkInterceptor.requests[0], + urlString: "https://web.stytch.com/sdk/v1/b2b/organizations/members/me", + method: .get + ) + } + + func testUpdate() async throws { + networkInterceptor.responses { + response + } + XCTAssertNil(StytchB2BClient.member.getSync()) + let updateMemberResponse = try await StytchB2BClient.member.update(parameters: .init(name: "foo bar", untrustedMetadata: ["blah": 1])) + XCTAssertNotNil(StytchB2BClient.member.getSync()) + XCTAssertEqual(updateMemberResponse.memberId, StytchB2BClient.member.getSync()?.memberId.rawValue) + try XCTAssertRequest( + networkInterceptor.requests[0], + urlString: "https://web.stytch.com/sdk/v1/b2b/organizations/members/update", + method: .put(["name": "foo bar", "untrusted_metadata": ["blah": 1]]) + ) + } + + func testDeletePhoneNumberFactor() async throws { + networkInterceptor.responses { + response + } + Current.localStorage.member = nil + XCTAssertNil(StytchB2BClient.member.getSync()) + _ = try await StytchB2BClient.member.deleteFactor(.phoneNumber) + XCTAssertNotNil(StytchB2BClient.member.getSync()) + try XCTAssertRequest( + networkInterceptor.requests[0], + urlString: "https://web.stytch.com/sdk/v1/b2b/organizations/members/deletePhoneNumber", + method: .delete + ) + } + + func testDeleteTotpFactor() async throws { + networkInterceptor.responses { + response + } + Current.localStorage.member = nil + XCTAssertNil(StytchB2BClient.member.getSync()) + _ = try await StytchB2BClient.member.deleteFactor(.totp) + XCTAssertNotNil(StytchB2BClient.member.getSync()) + try XCTAssertRequest( + networkInterceptor.requests[0], + urlString: "https://web.stytch.com/sdk/v1/b2b/organizations/members/deleteTOTP", + method: .delete + ) + } + + func testDeletePasswordFactor() async throws { + networkInterceptor.responses { + response + } + Current.localStorage.member = nil + XCTAssertNil(StytchB2BClient.member.getSync()) + _ = try await StytchB2BClient.member.deleteFactor(.password(passwordId: "passwordId-1234")) + XCTAssertNotNil(StytchB2BClient.member.getSync()) + try XCTAssertRequest( + networkInterceptor.requests[0], + urlString: "https://web.stytch.com/sdk/v1/b2b/organizations/members/passwords/passwordId-1234", + method: .delete + ) } }