diff --git a/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift b/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift index 78c1a20..1a63fa9 100644 --- a/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift +++ b/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift @@ -6,16 +6,46 @@ // SPDX-License-Identifier: MIT // +import Foundation import SpeziAccount +import SwiftUI -// TODO docs -public struct FirebaseEmailVerifiedKey: OptionalAccountValueKey { + +/// Flag indicating if the firebase account has a verified email address. +/// +/// - Important: This key is read-only and cannot be modified. +public struct FirebaseEmailVerifiedKey: AccountKey { public typealias Value = Bool + public static var name: LocalizedStringResource = "E-Mail Verified" + public static var category: AccountKeyCategory = .other + public static var initialValue: InitialValue = .default(false) +} + + +extension AccountKeys { + /// The email-verified ``FirebaseEmailVerifiedKey`` metatype. + public var isEmailVerified: FirebaseEmailVerifiedKey.Type { + FirebaseEmailVerifiedKey.self + } +} + + +extension AccountValues { + /// Access if the user's email of their firebase account is verified. + public var isEmailVerified: Bool { + storage[FirebaseEmailVerifiedKey.self] ?? false + } } -// this property is not supported in SignupRequests -extension AccountDetails { - public var isEmailVerified: Bool? { // swiftlint:disable:this discouraged_optional_boolean - storage[FirebaseEmailVerifiedKey.self] + +extension FirebaseEmailVerifiedKey { + public struct DataEntry: DataEntryView { + public typealias Key = FirebaseEmailVerifiedKey + + public var body: some View { + Text("The FirebaseEmailVerifiedKey cannot be set!") + } + + public init(_ value: Binding) {} } } diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift index e904d99..1b76d54 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift @@ -40,7 +40,7 @@ public final class FirebaseAccountConfiguration: Component { private let emulatorSettings: (host: String, port: Int)? private let authenticationMethods: FirebaseAuthAuthenticationMethods - public let accountService: FirebaseEmailPasswordAccountService // TODO this protocol requirement requires us to make the service public! + @Provide var accountServices: [any AccountService] /// - Parameters: /// - emulatorSettings: The emulator settings. The default value is `nil`, connecting the FirebaseAccount module to the Firebase Auth cloud instance. @@ -51,10 +51,11 @@ public final class FirebaseAccountConfiguration: Component { ) { self.emulatorSettings = emulatorSettings self.authenticationMethods = authenticationMethods + self.accountServices = [] - // TODO at least one authenticationMethod! - // if authenticationMethods.contains(.emailAndPassword) - self.accountService = FirebaseEmailPasswordAccountService() + if authenticationMethods.contains(.emailAndPassword) { + self.accountServices.append(FirebaseEmailPasswordAccountService()) + } } public func configure() { @@ -63,7 +64,12 @@ public final class FirebaseAccountConfiguration: Component { } Task { - await accountService.configure() + // We might be configured above the AccountConfiguration and therfore the `Account` object + // might not be injected yet. + try? await Task.sleep(for: .milliseconds(10)) + for accountService in accountServices { + await (accountService as? FirebaseEmailPasswordAccountService)?.configure() + } } } } diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountError.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountError.swift index eccb670..0ca8f62 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountError.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountError.swift @@ -17,6 +17,8 @@ enum FirebaseAccountError: LocalizedError { case invalidCredentials case internalPasswordResetError case setupError + case notSignedIn + case requireRecentLogin case unknown(AuthErrorCode.Code) @@ -34,6 +36,10 @@ enum FirebaseAccountError: LocalizedError { return "FIREBASE_ACCOUNT_FAILED_PASSWORD_RESET" case .setupError: return "FIREBASE_ACCOUNT_SETUP_ERROR" + case .notSignedIn: + return "FIREBASE_ACCOUNT_SIGN_IN_ERROR" + case .requireRecentLogin: + return "FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR" case .unknown: return "FIREBASE_ACCOUNT_UNKNOWN" } @@ -57,6 +63,10 @@ enum FirebaseAccountError: LocalizedError { return "FIREBASE_ACCOUNT_FAILED_PASSWORD_RESET_SUGGESTION" case .setupError: return "FIREBASE_ACCOUNT_SETUP_ERROR_SUGGESTION" + case .notSignedIn: + return "FIREBASE_ACCOUNT_SIGN_IN_ERROR_SUGGESTION" + case .requireRecentLogin: + return "FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR_SUGGESTION" case .unknown: return "FIREBASE_ACCOUNT_UNKNOWN_SUGGESTION" } @@ -81,6 +91,8 @@ enum FirebaseAccountError: LocalizedError { self = .internalPasswordResetError case .operationNotAllowed, .invalidAPIKey, .appNotAuthorized, .keychainError, .internalError: self = .setupError + case .requiresRecentLogin: + self = .requireRecentLogin default: self = .unknown(authErrorCode.code) } diff --git a/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift b/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift index 2c48e00..37797f0 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseEmailPasswordAccountService.swift @@ -7,12 +7,26 @@ // import FirebaseAuth +import OSLog import SpeziAccount import SwiftUI -// TODO do we want this actor requirement? -public actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { +enum StateChangeResult { + case user(_ user: User) + case removed +} + + +actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { + private static let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "AccountService") + + private static let supportedKeys = AccountKeyCollection { + \.userId + \.password + \.name + } + static var defaultPasswordValidationRule: ValidationRule { guard let regex = try? Regex(#"[^\s]{8,}"#) else { fatalError("Invalid Password Regex in the FirebaseEmailPasswordAccountService") @@ -25,30 +39,32 @@ public actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { ) } - @WeakInjectable // TODO AccountReference is internal! - private var account: Account + @AccountReference private var account: Account - public let configuration: UserIdPasswordServiceConfiguration + let configuration: AccountServiceConfiguration private var authStateDidChangeListenerHandle: AuthStateDidChangeListenerHandle? - // TODO make this configurable? + private var currentContinuation: CheckedContinuation? + init(passwordValidationRules: [ValidationRule] = [FirebaseEmailPasswordAccountService.defaultPasswordValidationRule]) { - self.configuration = .init( + self.configuration = AccountServiceConfiguration( name: LocalizedStringResource("FIREBASE_EMAIL_AND_PASSWORD", bundle: .atURL(from: .module)), - image: Image(systemName: "envelope.fill"), - signUpRequirements: AccountValueRequirements { - UserIdAccountValueKey.self - PasswordAccountValueKey.self - NameAccountValueKey.self - }, - userIdType: .emailAddress, - userIdField: .emailAddress, - userIdSignupValidations: [.minimalEmailValidationRule], - passwordSignupValidations: passwordValidationRules - ) + supportedKeys: .exactly(Self.supportedKeys) + ) { + AccountServiceImage(Image(systemName: "envelope.fill")) + RequiredAccountKeys { + \.userId + \.password + } + UserIdConfiguration(type: .emailAddress, keyboardType: .emailAddress) + + FieldValidationRules(for: \.userId, rules: .minimalEmail) + FieldValidationRules(for: \.password, rules: passwordValidationRules) + } } - public func configure() { + + func configure() { authStateDidChangeListenerHandle = Auth.auth().addStateDidChangeListener(stateDidChangeListener) // if there is a cached user, we refresh the authentication token @@ -61,15 +77,11 @@ public actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { } } - public func login(userId: String, password: String) async throws { + func login(userId: String, password: String) async throws { do { try await Auth.auth().signIn(withEmail: userId, password: password) - // TODO why did we trigger? - // Task { @MainActor in - // account?.objectWillChange.send() - // } - + try await continueWithStateChange() } catch let error as NSError { throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { @@ -77,29 +89,27 @@ public actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { } } - public func signUp(signupRequest: SignupRequest) async throws { + func signUp(signupDetails: SignupDetails) async throws { do { - let authResult = try await Auth.auth().createUser(withEmail: signupRequest.userId, password: signupRequest.password) - - let profileChangeRequest = authResult.user.createProfileChangeRequest() - profileChangeRequest.displayName = signupRequest.name.formatted(.name(style: .long)) - try await profileChangeRequest.commitChanges() + let authResult = try await Auth.auth().createUser(withEmail: signupDetails.userId, password: signupDetails.password) - // TODO why did we trigger? - // Task { @MainActor in - // account?.objectWillChange.send() - // } + if let displayName = signupDetails.name?.formatted(.name(style: .long)) { + let changeRequest = authResult.user.createProfileChangeRequest() + changeRequest.displayName = displayName + try await changeRequest.commitChanges() + } try await authResult.user.sendEmailVerification() + + try await continueWithStateChange() } catch let error as NSError { - // TODO can we inline the `invalidEmail` and `weakPassword` errors? => weakPassword has to be updated in the validation rule! throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { throw FirebaseAccountError.unknown(.internalError) } } - public func resetPassword(userId: String) async throws { + func resetPassword(userId: String) async throws { do { try await Auth.auth().sendPasswordReset(withEmail: userId) } catch let error as NSError { @@ -109,11 +119,67 @@ public actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { } } - public func logout() async throws { + func logout() async throws { + guard Auth.auth().currentUser != nil else { + throw FirebaseAccountError.notSignedIn + } + do { try Auth.auth().signOut() - // TODO verify that this results in the user getting removed? + try await continueWithStateChange() + } catch let error as NSError { + throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) + } catch { + throw FirebaseAccountError.unknown(.internalError) + } + } + + func delete() async throws { + guard let currentUser = Auth.auth().currentUser else { + throw FirebaseAccountError.notSignedIn + } + + do { + try await currentUser.delete() + + try await continueWithStateChange() + } catch let error as NSError { + throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) + } catch { + throw FirebaseAccountError.unknown(.internalError) + } + } + + func updateAccountDetails(_ modifications: AccountModifications) async throws { + guard let currentUser = Auth.auth().currentUser else { + throw FirebaseAccountError.notSignedIn + } + + var changes = false + + do { + if let userId = modifications.modifiedDetails.storage[UserIdKey.self] { + try await currentUser.updateEmail(to: userId) + changes = true + } + + if let password = modifications.modifiedDetails.password { + try await currentUser.updatePassword(to: password) + changes = true + } + + if let name = modifications.modifiedDetails.name { + let changeRequest = currentUser.createProfileChangeRequest() + changeRequest.displayName = name.formatted(.name(style: .long)) + try await changeRequest.commitChanges() + + changes = true + } + + if changes { + try await continueWithStateChange() + } } catch let error as NSError { throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { @@ -122,38 +188,71 @@ public actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService { } private func stateDidChangeListener(auth: Auth, user: User?) { - Task { - if let user { - await notifyUserSignIn(user: user) - } else { - await notifyUserRemoval() + // this is called by the FIRAuth framework. + + let result: StateChangeResult + if let user { + result = .user(user) + } else { + result = .removed + } + + // if we have a current continuation waiting for our result, resume there + if let currentContinuation { + currentContinuation.resume(returning: result) + self.currentContinuation = nil + } else { + // Otherwise, there might still be cases where changes are triggered externally. + // We cannot sensibly display any error messages then, though. + Task { + do { + try await updateUser(result) + } catch { + // currently, this only happens if the storage standard fails to load the additional user record + Self.logger.error("Failed to execute remote user change: \(error)") + } } } } - func notifyUserSignIn(user: User) async { - guard let email = user.email, - let displayName = user.displayName else { - // TODO log - return // TODO how to propagate back the error? + func continueWithStateChange() async throws { + let result: StateChangeResult = await withCheckedContinuation { continuation in + self.currentContinuation = continuation } - guard let nameComponents = try? PersonNameComponents(displayName) else { + try await updateUser(result) + } + + func updateUser(_ state: StateChangeResult) async throws { + switch state { + case let .user(user): + try await notifyUserSignIn(user: user) + case .removed: + await notifyUserRemoval() + } + } + + func notifyUserSignIn(user: User) async throws { + guard let email = user.email else { + throw FirebaseAccountError.invalidEmail + } + + let builder = AccountDetails.Builder() + .set(\.userId, value: email) + .set(\.isEmailVerified, value: user.isEmailVerified) + + if let displayName = user.displayName, + let nameComponents = try? PersonNameComponents(displayName) { // we wouldn't be here if we couldn't create the person name components from the given string - // TODO log (still show error somehow?) - return + builder.set(\.name, value: nameComponents) } - let details = AccountDetails.Builder() - .add(UserIdAccountValueKey.self, value: email) - .add(NameAccountValueKey.self, value: nameComponents) - .add(FirebaseEmailVerifiedKey.self, value: user.isEmailVerified) - .build(owner: self) - await account.supplyUserInfo(details) + let details = builder.build(owner: self) + try await account.supplyUserDetails(details) } func notifyUserRemoval() async { - await account.removeUserInfo() + await account.removeUserDetails() } } diff --git a/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings b/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings index c5b80b2..8448176 100644 --- a/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings +++ b/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings @@ -29,5 +29,11 @@ "FIREBASE_ACCOUNT_SETUP_ERROR" = "Benutzerkonto konnte nicht erstelle werden"; "FIREBASE_ACCOUNT_SETUP_ERROR_SUGGESTION" = "Es ist ein Fehler aufgetreten beim erstellen deines Benutzerkontos. Bitte versuche Sie es später erneut."; -"FIREBASE_ACCOUNT_UNKNOWN" = "Benutzerkonto konnte nicht erstelle werden"; -"FIREBASE_ACCOUNT_UNKNOWN_SUGGESTION" = "Bitte überprüfen Sie Ihre E-Mail Adresse und versuchen Sie es erneut."; +"FIREBASE_ACCOUNT_SIGN_IN_ERROR" = "Nicht eingeloggt"; +"FIREBASE_ACCOUNT_SIGN_IN_ERROR_SUGGESTION" = "Die Anfrage konnte nicht ausgeführt werden weil kein verknüpftes Benutzerkonto gefunden wurde."; + +"FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR" = "Anfrage fehlgeschlagen"; +"FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR_SUGGESTION" = "Diese Anfrage is sicherheitsrelevant und erfordert einen kürzlichen Login."; + +"FIREBASE_ACCOUNT_UNKNOWN" = "Anfrage fehlgeschlagen"; +"FIREBASE_ACCOUNT_UNKNOWN_SUGGESTION" = "Bitte überprüfen deine Internet Verbindung und versuche es erneut."; diff --git a/Sources/SpeziFirebaseAccount/Resources/en.lproj/Localizable.strings b/Sources/SpeziFirebaseAccount/Resources/en.lproj/Localizable.strings index 12880dc..c935708 100644 --- a/Sources/SpeziFirebaseAccount/Resources/en.lproj/Localizable.strings +++ b/Sources/SpeziFirebaseAccount/Resources/en.lproj/Localizable.strings @@ -29,5 +29,11 @@ "FIREBASE_ACCOUNT_SETUP_ERROR" = "Could not create the user account"; "FIREBASE_ACCOUNT_SETUP_ERROR_SUGGESTION" = "There was an internal error when trying to create your user account. Please try again later."; -"FIREBASE_ACCOUNT_UNKNOWN" = "Could not create the user account"; +"FIREBASE_ACCOUNT_SIGN_IN_ERROR" = "Not signed in"; +"FIREBASE_ACCOUNT_SIGN_IN_ERROR_SUGGESTION" = "Couldn't complete this operation as there is no current user account."; + +"FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR" = "Couldn't complete operation"; +"FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR_SUGGESTION" = "This is a security relevant operation that requires a recent login."; + +"FIREBASE_ACCOUNT_UNKNOWN" = "Failed account operation"; "FIREBASE_ACCOUNT_UNKNOWN_SUGGESTION" = "Please check your internet connection and try again."; diff --git a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift index a573e20..58fd019 100644 --- a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift +++ b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift @@ -18,22 +18,53 @@ struct FirebaseAccountTestsView: View { @EnvironmentObject var account: Account @State var viewState: ViewState = .idle + + @State var showSetup = false + @State var showOverview = false + @State var isEditing = false var body: some View { List { if let details = account.details { HStack { - UserProfileView(name: details.name) + UserProfileView(name: details.name ?? .init(givenName: "NOT FOUND")) .frame(height: 30) - Text(details.userId) // TODO specific email key? + Text(details.userId) + } + } + Button("Account Setup") { + showSetup = true + } + Button("Account Overview") { + showOverview = true + } + .sheet(isPresented: $showSetup) { + NavigationStack { + AccountSetup() + } + .toolbar { + toolbar(closing: $showSetup) + } } + .sheet(isPresented: $showOverview) { + NavigationStack { + AccountOverview(isEditing: $isEditing) + } + .toolbar { + toolbar(closing: $showOverview) + } + } + } + } + - // TODO rename this thing and move to SpeziViews! - AsyncDataEntrySubmitButton("Logout", state: $viewState) { - try await details.accountService.logout() + @ToolbarContentBuilder + func toolbar(closing flag: Binding) -> some ToolbarContent { + if !isEditing { + ToolbarItemGroup(placement: .cancellationAction) { + Button("Close") { + flag.wrappedValue = false } - } else { - AccountSetup() // TODO external parameter name } } } diff --git a/Tests/UITests/TestApp/GoogleService-Info.plist b/Tests/UITests/TestApp/GoogleService-Info.plist index 743e642..efd4c5e 100644 --- a/Tests/UITests/TestApp/GoogleService-Info.plist +++ b/Tests/UITests/TestApp/GoogleService-Info.plist @@ -13,7 +13,7 @@ PLIST_VERSION 1 BUNDLE_ID - edu.stanford.spezi.firebase + edu.stanford.spezi.firebase.testapp PROJECT_ID spezifirebaseuitests STORAGE_BUCKET diff --git a/Tests/UITests/TestApp/Shared/TestAppDelegate.swift b/Tests/UITests/TestApp/Shared/TestAppDelegate.swift index 2ac812d..c3657e6 100644 --- a/Tests/UITests/TestApp/Shared/TestAppDelegate.swift +++ b/Tests/UITests/TestApp/Shared/TestAppDelegate.swift @@ -7,6 +7,7 @@ // import Spezi +import SpeziAccount import SpeziFirebaseAccount import SpeziFirestore import SwiftUI @@ -15,6 +16,11 @@ import SwiftUI class TestAppDelegate: SpeziAppDelegate { override var configuration: Configuration { Configuration { + AccountConfiguration(configuration: [ + .requires(\.userId), + .requires(\.password), + .collects(\.name) + ]) Firestore(settings: .emulator) FirebaseAccountConfiguration(emulatorSettings: (host: "localhost", port: 9099)) } diff --git a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 833de11..76a0822 100644 --- a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -111,10 +111,10 @@ { "identity" : "speziaccount", "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordSpezi/SpeziAccount", + "location" : "https://github.com/StanfordSpezi/SpeziAccount.git", "state" : { "branch" : "feature/simple-account-view", - "revision" : "9290d37ce2c631e241127662654a37b34de18281" + "revision" : "c3e4300a1486cae0eda552569babc5ddcf3681e6" } }, { @@ -140,8 +140,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "ce20dc083ee485524b802669890291c0d8090170", - "version" : "1.22.1" + "revision" : "cf62cdaea48b77f1a631e5cb3aeda6047c2cba1d", + "version" : "1.23.0" } }, { diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme index f6731e0..fc3757d 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme @@ -8,16 +8,16 @@ + BlueprintIdentifier = "2F6D139128F5F384007C25D6" + BuildableName = "TestApp.app" + BlueprintName = "TestApp" + ReferencedContainer = "container:UITests.xcodeproj"> + +