From 084d697f58dc1a3275677992f6a5884736e3e2f6 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sun, 12 Nov 2023 16:58:38 -0800 Subject: [PATCH] Upgrade Spezi and SpeziAccount (#23) # Upgrade Spezi and SpeziAccount ## :recycle: Current situation & Problem This PR updates to the latest releases of Spezi and SpeziAccount. By employing the newly release framework features we are resolving #14, #15 and removing the modifier workaround to retrieve the authorization controller again. For more information see https://github.com/StanfordSpezi/SpeziAccount/pull/36. ## :gear: Release Notes * Upgrade SpeziAccount. * Upgrade Spezi. * Removed the `firebaseAccount(_:)` modifier. * Migrated to String Catalogs ## :books: Documentation -- ## :white_check_mark: Testing -- ## :pencil: Code of Conduct & Contributing Guidelines By submitting creating this pull request, you agree to follow our [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md): - [x] I agree to follow the [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md). Fo --- .github/workflows/markdown-lint-check.yml | 19 + .github/workflows/pull_request.yml | 3 + Package.swift | 8 +- .../FirebaseAccountService.swift | 48 +- .../FirebaseEmailPasswordAccountService.swift | 77 ++-- ...rebaseIdentityProviderAccountService.swift | 29 +- .../FirebaseEmailVerifiedKey.swift | 2 +- .../FirebaseOAuthCredential.swift | 4 +- .../FirebaseAccountConfiguration.swift | 28 +- .../Models/FirebaseAccountModel.swift | 35 ++ .../{Utils => Models}/FirebaseContext.swift | 29 +- .../Models/ReauthenticationContext.swift | 22 + .../Resources/Localizable.xcstrings | 412 ++++++++++++++++++ .../Resources/Localizable.xcstrings.license | 5 + .../Resources/de.lproj/Localizable.strings | 43 -- .../Resources/en.lproj/Localizable.strings | 43 -- .../Views/FirebaseAccountModifier.swift | 61 +-- .../Views/ReauthenticationAlertModifier.swift | 84 ++++ .../ConfigureFirebaseApp.swift | 2 +- .../FirebaseStorageConfiguration.swift | 2 +- .../Resources/Localizable.xcstrings | 310 +++++++++++++ .../Resources/Localizable.xcstrings.license | 5 + .../Resources/de.lproj/Localizable.strings | 30 -- .../Resources/en.lproj/Localizable.strings | 30 -- .../FirebaseAccountTestsView.swift | 5 +- .../TestAppUITests/FirebaseAccountTests.swift | 78 +++- .../TestAppUITests/FirebaseStorageTests.swift | 4 +- .../FirestoreDataStorageTests.swift | 4 +- .../UITests/UITests.xcodeproj/project.pbxproj | 173 +++++--- .../xcshareddata/swiftpm/Package.resolved | 47 +- 30 files changed, 1251 insertions(+), 391 deletions(-) create mode 100644 .github/workflows/markdown-lint-check.yml create mode 100644 Sources/SpeziFirebaseAccount/Models/FirebaseAccountModel.swift rename Sources/SpeziFirebaseAccount/{Utils => Models}/FirebaseContext.swift (90%) create mode 100644 Sources/SpeziFirebaseAccount/Models/ReauthenticationContext.swift create mode 100644 Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings create mode 100644 Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings.license delete mode 100644 Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings delete mode 100644 Sources/SpeziFirebaseAccount/Resources/en.lproj/Localizable.strings create mode 100644 Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift create mode 100644 Sources/SpeziFirestore/Resources/Localizable.xcstrings create mode 100644 Sources/SpeziFirestore/Resources/Localizable.xcstrings.license delete mode 100644 Sources/SpeziFirestore/Resources/de.lproj/Localizable.strings delete mode 100644 Sources/SpeziFirestore/Resources/en.lproj/Localizable.strings diff --git a/.github/workflows/markdown-lint-check.yml b/.github/workflows/markdown-lint-check.yml new file mode 100644 index 0000000..9a2d1f2 --- /dev/null +++ b/.github/workflows/markdown-lint-check.yml @@ -0,0 +1,19 @@ +# +# This source file is part of the Stanford Spezi open source project +# +# SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +# +# SPDX-License-Identifier: MIT +# + +name: Monthly Markdown Link Check + +on: + # Runs at midnight on the first of every month + schedule: + - cron: "0 0 1 * *" + +jobs: + markdown_link_check: + name: Markdown Link Check + uses: StanfordBDHG/.github/.github/workflows/markdown-link-check.yml@v2 diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index eb53387..5a7d62c 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -19,3 +19,6 @@ jobs: swiftlint: name: SwiftLint uses: StanfordSpezi/.github/.github/workflows/swiftlint.yml@v2 + markdown_link_check: + name: Markdown Link Check + uses: StanfordBDHG/.github/.github/workflows/markdown-link-check.yml@v2 diff --git a/Package.swift b/Package.swift index 0dbddfd..83d684d 100644 --- a/Package.swift +++ b/Package.swift @@ -24,9 +24,10 @@ let package = Package( .library(name: "SpeziFirebaseStorage", targets: ["SpeziFirebaseStorage"]) ], dependencies: [ - .package(url: "https://github.com/StanfordSpezi/Spezi", .upToNextMinor(from: "0.7.0")), - .package(url: "https://github.com/StanfordSpezi/SpeziAccount", .upToNextMinor(from: "0.6.1")), - .package(url: "https://github.com/StanfordSpezi/SpeziStorage", .upToNextMinor(from: "0.4.2")), + .package(url: "https://github.com/StanfordSpezi/Spezi", .upToNextMinor(from: "0.8.0")), + .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", .upToNextMinor(from: "0.6.1")), + .package(url: "https://github.com/StanfordSpezi/SpeziAccount", .upToNextMinor(from: "0.7.1")), + .package(url: "https://github.com/StanfordSpezi/SpeziStorage", .upToNextMinor(from: "0.5.0")), .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "10.13.0") ], targets: [ @@ -35,6 +36,7 @@ let package = Package( dependencies: [ .target(name: "SpeziFirebaseConfiguration"), .product(name: "Spezi", package: "Spezi"), + .product(name: "SpeziValidation", package: "SpeziViews"), .product(name: "SpeziAccount", package: "SpeziAccount"), .product(name: "SpeziLocalStorage", package: "SpeziStorage"), .product(name: "SpeziSecureStorage", package: "SpeziStorage"), diff --git a/Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountService.swift b/Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountService.swift index 2c6d5d6..c9e06fb 100644 --- a/Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountService.swift +++ b/Sources/SpeziFirebaseAccount/Account Services/FirebaseAccountService.swift @@ -12,6 +12,11 @@ import OSLog import SpeziAccount import SwiftUI +enum ReauthenticationOperationResult { + case cancelled + case success +} + protocol FirebaseAccountService: AnyActor, AccountService { static var logger: Logger { get } @@ -26,18 +31,12 @@ protocol FirebaseAccountService: AnyActor, AccountService { /// - Parameter context: The global firebase context func configure(with context: FirebaseContext) async - func inject(authorizationController: AuthorizationController) async - - /// This method is called once the account for the given user was removed. - /// - /// This allows for additional cleanup tasks to be performed. - /// - Parameter userId: The userId which was removed, or nil if we couldn't retrieve the last user. - func handleAccountRemoval(userId: String?) async - /// This method is called to re-authenticate the current user credentials. - /// - Parameters: - /// - user: The User instance. - func reauthenticateUser(user: User) async throws + /// + /// - Parameter user: The user instance to reauthenticate. + /// - Returns: `true` if authentication was successful, `false` if authentication was cancelled by the user. + /// - Throws: If authentication failed. + func reauthenticateUser(user: User) async throws -> ReauthenticationOperationResult } @@ -74,7 +73,12 @@ extension FirebaseAccountService { } try await context.dispatchFirebaseAuthAction(on: self) { - try await reauthenticateUser(user: currentUser) // delete requires a recent sign in + let result = try await reauthenticateUser(user: currentUser) // delete requires a recent sign in + guard case .success = result else { + Self.logger.debug("Re-authentication was cancelled. Not deleting the account.") + return // cancelled + } + try await currentUser.delete() Self.logger.debug("delete() for user.") } @@ -90,12 +94,16 @@ extension FirebaseAccountService { var changes = false - // if we modify sensitive credentials and require a recent login - if modifications.modifiedDetails.storage[UserIdKey.self] != nil || modifications.modifiedDetails.password != nil { - try await reauthenticateUser(user: currentUser) - } - do { + // if we modify sensitive credentials and require a recent login + if modifications.modifiedDetails.storage[UserIdKey.self] != nil || modifications.modifiedDetails.password != nil { + let result = try await reauthenticateUser(user: currentUser) + guard case .success = result else { + Self.logger.debug("Re-authentication was cancelled. Not deleting the account.") + return // got cancelled! + } + } + if let userId = modifications.modifiedDetails.storage[UserIdKey.self] { Self.logger.debug("updateEmail(to:) for user.") try await currentUser.updateEmail(to: userId) @@ -105,10 +113,6 @@ extension FirebaseAccountService { if let password = modifications.modifiedDetails.password { Self.logger.debug("updatePassword(to:) for user.") try await currentUser.updatePassword(to: password) - - if let userId = currentUser.email { // make sure we save the new password - await context.persistCurrentCredentials(userId: userId, password: password, server: StorageKeys.emailPasswordCredentials) - } } if let name = modifications.modifiedDetails.name { @@ -125,8 +129,10 @@ extension FirebaseAccountService { try await context.notifyUserSignIn(user: currentUser, for: self) } } catch let error as NSError { + Self.logger.error("Received NSError on firebase dispatch: \(error)") throw FirebaseAccountError(authErrorCode: AuthErrorCode(_nsError: error)) } catch { + Self.logger.error("Received error on firebase dispatch: \(error)") throw FirebaseAccountError.unknown(.internalError) } } diff --git a/Sources/SpeziFirebaseAccount/Account Services/FirebaseEmailPasswordAccountService.swift b/Sources/SpeziFirebaseAccount/Account Services/FirebaseEmailPasswordAccountService.swift index b97b414..d4b01ae 100644 --- a/Sources/SpeziFirebaseAccount/Account Services/FirebaseEmailPasswordAccountService.swift +++ b/Sources/SpeziFirebaseAccount/Account Services/FirebaseEmailPasswordAccountService.swift @@ -11,9 +11,19 @@ import FirebaseAuth import OSLog import SpeziAccount import SpeziSecureStorage +import SpeziValidation import SwiftUI +struct EmailPasswordViewStyle: UserIdPasswordAccountSetupViewStyle { + let service: FirebaseEmailPasswordAccountService + + var securityRelatedViewModifier: any ViewModifier { + ReauthenticationAlertModifier() + } +} + + actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService, FirebaseAccountService { static let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "AccountService") @@ -24,26 +34,19 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService, Firebas \.name } - static var minimumFirebasePassword: ValidationRule { - // Firebase as a non-configurable limit of 6 characters for an account password. - // Refer to https://stackoverflow.com/questions/38064248/firebase-password-validation-allowed-regex - guard let regex = try? Regex(#"(?=.*[0-9a-zA-Z]).{6,}"#) else { - fatalError("Invalid minimumFirebasePassword regex at construction.") - } - - return ValidationRule( - regex: regex, - message: "FIREBASE_ACCOUNT_DEFAULT_PASSWORD_RULE_ERROR \(6)", - bundle: .module - ) - } @AccountReference var account: Account @_WeakInjectable var context: FirebaseContext let configuration: AccountServiceConfiguration + let firebaseModel: FirebaseAccountModel + + nonisolated var viewStyle: EmailPasswordViewStyle { + EmailPasswordViewStyle(service: self) + } - init(passwordValidationRules: [ValidationRule] = [minimumFirebasePassword]) { + + init(_ model: FirebaseAccountModel, passwordValidationRules: [ValidationRule] = [.minimumFirebasePassword]) { self.configuration = AccountServiceConfiguration( name: LocalizedStringResource("FIREBASE_EMAIL_AND_PASSWORD", bundle: .atURL(from: .module)), supportedKeys: .exactly(Self.supportedKeys) @@ -58,19 +61,15 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService, Firebas FieldValidationRules(for: \.userId, rules: .minimalEmail) FieldValidationRules(for: \.password, rules: passwordValidationRules) } + self.firebaseModel = model } + func configure(with context: FirebaseContext) async { self._context.inject(context) await context.share(account: account) } - func handleAccountRemoval(userId: String?) { - if let userId { - context.removeCredentials(userId: userId, server: StorageKeys.emailPasswordCredentials) - } - } - func login(userId: String, password: String) async throws { Self.logger.debug("Received new login request...") @@ -78,8 +77,6 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService, Firebas try await Auth.auth().signIn(withEmail: userId, password: password) Self.logger.debug("signIn(withEmail:password:)") } - - context.persistCurrentCredentials(userId: userId, password: password, server: StorageKeys.emailPasswordCredentials) } func signUp(signupDetails: SignupDetails) async throws { @@ -103,8 +100,6 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService, Firebas try await changeRequest.commitChanges() } } - - context.persistCurrentCredentials(userId: signupDetails.userId, password: password, server: StorageKeys.emailPasswordCredentials) } func resetPassword(userId: String) async throws { @@ -123,20 +118,36 @@ actor FirebaseEmailPasswordAccountService: UserIdPasswordAccountService, Firebas } } - func reauthenticateUser(user: User) async { + func reauthenticateUser(user: User) async throws -> ReauthenticationOperationResult { guard let userId = user.email else { - return + return .cancelled } - // with a future version of SpeziAccount we want to get rid of this workaround and request the password from the user on the fly. - guard let password = context.retrieveCredential(userId: userId, server: StorageKeys.emailPasswordCredentials) else { - return // nothing we can do + Self.logger.debug("Requesting credentials for re-authentication...") + let passwordQuery = await firebaseModel.reauthenticateUser(userId: userId) + guard case let .password(password) = passwordQuery else { + return .cancelled } - do { - try await user.reauthenticate(with: EmailAuthProvider.credential(withEmail: userId, password: password)) - } catch { - Self.logger.debug("Credential change might fail. Failed to reauthenticate with firebase: \(error)") + Self.logger.debug("Re-authenticating password-based user now ...") + try await user.reauthenticate(with: EmailAuthProvider.credential(withEmail: userId, password: password)) + return .success + } +} + + +extension ValidationRule { + static var minimumFirebasePassword: ValidationRule { + // Firebase as a non-configurable limit of 6 characters for an account password. + // Refer to https://stackoverflow.com/questions/38064248/firebase-password-validation-allowed-regex + guard let regex = try? Regex(#"(?=.*[0-9a-zA-Z]).{6,}"#) else { + fatalError("Invalid minimumFirebasePassword regex at construction.") } + + return ValidationRule( + regex: regex, + message: "FIREBASE_ACCOUNT_DEFAULT_PASSWORD_RULE_ERROR \(6)", + bundle: .module + ) } } diff --git a/Sources/SpeziFirebaseAccount/Account Services/FirebaseIdentityProviderAccountService.swift b/Sources/SpeziFirebaseAccount/Account Services/FirebaseIdentityProviderAccountService.swift index 140d816..c4fa9cf 100644 --- a/Sources/SpeziFirebaseAccount/Account Services/FirebaseIdentityProviderAccountService.swift +++ b/Sources/SpeziFirebaseAccount/Account Services/FirebaseIdentityProviderAccountService.swift @@ -42,15 +42,14 @@ actor FirebaseIdentityProviderAccountService: IdentityProvider, FirebaseAccountS } let configuration: AccountServiceConfiguration - - private var authorizationController: AuthorizationController? + let firebaseModel: FirebaseAccountModel @MainActor @AccountReference var account: Account // property wrappers cannot be non-isolated, so we isolate it to main actor @MainActor private var lastNonce: String? @_WeakInjectable var context: FirebaseContext - init() { + init(_ model: FirebaseAccountModel) { self.configuration = AccountServiceConfiguration( name: LocalizedStringResource("FIREBASE_IDENTITY_PROVIDER", bundle: .atURL(from: .module)), supportedKeys: .exactly(Self.supportedKeys) @@ -60,6 +59,7 @@ actor FirebaseIdentityProviderAccountService: IdentityProvider, FirebaseAccountS } UserIdConfiguration(type: .emailAddress, keyboardType: .emailAddress) } + self.firebaseModel = model } @@ -68,23 +68,15 @@ actor FirebaseIdentityProviderAccountService: IdentityProvider, FirebaseAccountS await context.share(account: account) } - func inject(authorizationController: AuthorizationController) { - Self.logger.debug("Received authorization controller injection ...") - self.authorizationController = authorizationController - } - - func handleAccountRemoval(userId: String?) async { - // nothing we are doing here - } - - func reauthenticateUser(user: User) async throws { + func reauthenticateUser(user: User) async throws -> ReauthenticationOperationResult { guard let appleIdCredential = try await requestAppleSignInCredential() else { - return // user canceled + return .cancelled } let credential = try await oAuthCredential(from: appleIdCredential) try await user.reauthenticate(with: credential) + return .success } func signUp(signupDetails: SignupDetails) async throws { @@ -108,7 +100,7 @@ actor FirebaseIdentityProviderAccountService: IdentityProvider, FirebaseAccountS throw FirebaseAccountError.notSignedIn } - try await context.dispatchFirebaseAuthAction(on: self) { () -> Void in + try await context.dispatchFirebaseAuthAction(on: self) { guard let credential = try await requestAppleSignInCredential() else { return // user canceled } @@ -239,11 +231,8 @@ actor FirebaseIdentityProviderAccountService: IdentityProvider, FirebaseAccountS } private func performRequest(_ request: ASAuthorizationAppleIDRequest) async throws -> ASAuthorizationResult? { - guard let authorizationController else { - Self.logger.error(""" - Failed to perform AppleID request. We are missing access to the AuthorizationController. \ - Did you set up the .firebaseAccount() modifier? - """) + guard let authorizationController = firebaseModel.authorizationController else { + Self.logger.error("Failed to perform AppleID request. We are missing access to the AuthorizationController.") throw FirebaseAccountError.setupError } diff --git a/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift b/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift index 605e8d7..7d32861 100644 --- a/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift +++ b/Sources/SpeziFirebaseAccount/AccountValues/FirebaseEmailVerifiedKey.swift @@ -43,7 +43,7 @@ extension FirebaseEmailVerifiedKey { public typealias Key = FirebaseEmailVerifiedKey public var body: some View { - Text("The FirebaseEmailVerifiedKey cannot be set!") + Text(verbatim: "The FirebaseEmailVerifiedKey cannot be set!") } public init(_ value: Binding) {} diff --git a/Sources/SpeziFirebaseAccount/AccountValues/FirebaseOAuthCredential.swift b/Sources/SpeziFirebaseAccount/AccountValues/FirebaseOAuthCredential.swift index 55425c1..0fda2a4 100644 --- a/Sources/SpeziFirebaseAccount/AccountValues/FirebaseOAuthCredential.swift +++ b/Sources/SpeziFirebaseAccount/AccountValues/FirebaseOAuthCredential.swift @@ -59,7 +59,7 @@ extension FirebaseOAuthCredentialKey { public typealias Key = FirebaseOAuthCredentialKey public var body: some View { - Text("The FirebaseOAuthCredentialKey cannot be set!") + Text(verbatim: "The FirebaseOAuthCredentialKey cannot be set!") } public init(_ value: Binding) {} @@ -69,7 +69,7 @@ extension FirebaseOAuthCredentialKey { public typealias Key = FirebaseOAuthCredentialKey public var body: some View { - Text("The FirebaseOAuthCredentialKey cannot be displayed!") + Text(verbatim: "The FirebaseOAuthCredentialKey cannot be displayed!") } public init(_ value: Value) {} diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift index c617627..f3dcad3 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountConfiguration.swift @@ -36,15 +36,20 @@ import SpeziSecureStorage /// } /// } /// ``` -public final class FirebaseAccountConfiguration: Component { +public final class FirebaseAccountConfiguration: Module { @Dependency private var configureFirebaseApp: ConfigureFirebaseApp @Dependency private var secureStorage: SecureStorage @Dependency private var localStorage: LocalStorage + @Dependency private var speziAccount: AccountConfiguration? + + @Provide private var accountServices: [any AccountService] + + @Model private var accountModel = FirebaseAccountModel() + @Modifier private var firebaseModifier = FirebaseAccountModifier() private let emulatorSettings: (host: String, port: Int)? private let authenticationMethods: FirebaseAuthAuthenticationMethods - @Provide var accountServices: [any AccountService] /// Central context management for all account service implementations. private var context: FirebaseContext? @@ -61,10 +66,10 @@ public final class FirebaseAccountConfiguration: Component { self.accountServices = [] if authenticationMethods.contains(.emailAndPassword) { - self.accountServices.append(FirebaseEmailPasswordAccountService()) + self.accountServices.append(FirebaseEmailPasswordAccountService(accountModel)) } if authenticationMethods.contains(.signInWithApple) { - self.accountServices.append(FirebaseIdentityProviderAccountService()) + self.accountServices.append(FirebaseIdentityProviderAccountService(accountModel)) } } @@ -73,11 +78,16 @@ public final class FirebaseAccountConfiguration: Component { Auth.auth().useEmulator(withHost: emulatorSettings.host, port: emulatorSettings.port) } - Task { - // We might be configured above the AccountConfiguration and therefore the `Account` object - // might not be injected yet. - try? await Task.sleep(for: .milliseconds(10)) + guard speziAccount != nil else { + preconditionFailure(""" + Missing Account Configuration! + FirebaseAccount was configured but no \(AccountConfiguration.self) was provided. Please \ + refer to the initial setup instructions of SpeziAccount: https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/initial-setup + """) + } + + Task { let context = FirebaseContext(local: localStorage, secure: secureStorage) let firebaseServices = accountServices.compactMap { service in service as? any FirebaseAccountService @@ -88,7 +98,7 @@ public final class FirebaseAccountConfiguration: Component { } await context.setup(firebaseServices) - self.context = context + self.context = context // we inject as weak, so ensure to keep the reference here! } } } diff --git a/Sources/SpeziFirebaseAccount/Models/FirebaseAccountModel.swift b/Sources/SpeziFirebaseAccount/Models/FirebaseAccountModel.swift new file mode 100644 index 0000000..88ccead --- /dev/null +++ b/Sources/SpeziFirebaseAccount/Models/FirebaseAccountModel.swift @@ -0,0 +1,35 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import AuthenticationServices +import Observation +import SwiftUI + + +@Observable +class FirebaseAccountModel { + var authorizationController: AuthorizationController? + + var isPresentingReauthentication = false + var reauthenticationContext: ReauthenticationContext? + + init() {} + + + func reauthenticateUser(userId: String) async -> ReauthenticationResult { + defer { + reauthenticationContext = nil + isPresentingReauthentication = false + } + + return await withCheckedContinuation { continuation in + isPresentingReauthentication = true + reauthenticationContext = ReauthenticationContext(userId: userId, continuation: continuation) + } + } +} diff --git a/Sources/SpeziFirebaseAccount/Utils/FirebaseContext.swift b/Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift similarity index 90% rename from Sources/SpeziFirebaseAccount/Utils/FirebaseContext.swift rename to Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift index 9069994..82d443e 100644 --- a/Sources/SpeziFirebaseAccount/Utils/FirebaseContext.swift +++ b/Sources/SpeziFirebaseAccount/Models/FirebaseContext.swift @@ -118,16 +118,7 @@ actor FirebaseContext { } } - nonisolated func persistCurrentCredentials(userId: String, password: String, server: String) { - let passwordCredential = Credentials(username: userId, password: password) - do { - try secureStorage.store(credentials: passwordCredential, server: server, storageScope: .keychain) - } catch { - Self.logger.error("Failed to persists login credentials: \(error)") - } - } - - nonisolated func removeCredentials(userId: String, server: String) { + private nonisolated func removeCredentials(userId: String, server: String) { do { try secureStorage.deleteCredentials(userId, server: server) } catch { @@ -135,16 +126,6 @@ actor FirebaseContext { } } - nonisolated func retrieveCredential(userId: String, server: String) -> String? { - do { - return try secureStorage.retrieveCredentials(userId, server: server)?.password - } catch { - Self.logger.error("Failed to retrieve credentials: \(error)") - } - - return nil - } - private func setActiveAccountService(to service: any FirebaseAccountService) { self.lastActiveAccountServiceId = service.id self.lastActiveAccountService = service @@ -280,17 +261,19 @@ actor FirebaseContext { } let details = builder.build(owner: service) + + // Previous SpeziFirebase releases used to store the password within the keychain. + // We keep this for now, to clear the keychain of all users. + removeCredentials(userId: details.userId, server: StorageKeys.emailPasswordCredentials) + try await account.supplyUserDetails(details, isNewUser: isNewUser) } func notifyUserRemoval(for service: (any FirebaseAccountService)?) async throws { Self.logger.debug("Notifying SpeziAccount of removed user details.") - let userId = await account.details?.userId await account.removeUserDetails() resetActiveAccountService() - - await service?.handleAccountRemoval(userId: userId) } } diff --git a/Sources/SpeziFirebaseAccount/Models/ReauthenticationContext.swift b/Sources/SpeziFirebaseAccount/Models/ReauthenticationContext.swift new file mode 100644 index 0000000..2e5c6e6 --- /dev/null +++ b/Sources/SpeziFirebaseAccount/Models/ReauthenticationContext.swift @@ -0,0 +1,22 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +enum ReauthenticationResult { + case cancelled + case password(_ password: String) +} + + +struct ReauthenticationContext { + /// The userId for which we are doing the re-authentication. + let userId: String + + /// A continuation that accepts the password the user, once retrieved! + let continuation: CheckedContinuation +} diff --git a/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings b/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings new file mode 100644 index 0000000..871ab56 --- /dev/null +++ b/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings @@ -0,0 +1,412 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Authentication Required" : { + + }, + "Cancel" : { + + }, + "E-Mail Verified" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "E-Mail verifiziert" + } + } + } + }, + "FIREBASE_ACCOUNT_ALREADY_IN_USE" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Benutzerkonto existiert bereits" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Account already exists" + } + } + } + }, + "FIREBASE_ACCOUNT_ALREADY_IN_USE_SUGGESTION" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ein Benutzerkonto mit dieser E-Mail Adresse existiert bereits. Bitte loggen dich mit dem Account ein." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "An account with this email already exists. Please login using your email and password." + } + } + } + }, + "FIREBASE_ACCOUNT_DEFAULT_PASSWORD_RULE_ERROR %lld" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dein Passwort muss mindestens %lld Zeichen lang sein." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your password must be at least %lld characters long." + } + } + } + }, + "FIREBASE_ACCOUNT_ERROR_INVALID_EMAIL" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inkorrekte E-Mail Adresse" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invalid email address" + } + } + } + }, + "FIREBASE_ACCOUNT_ERROR_INVALID_EMAIL_SUGGESTION" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitte gebe eine korrekte E-Mail Adresse ein." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please enter a valid email address." + } + } + } + }, + "FIREBASE_ACCOUNT_FAILED_PASSWORD_RESET" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zurücksetzten des Passworts fehlgeschlagen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to reset password" + } + } + } + }, + "FIREBASE_ACCOUNT_FAILED_PASSWORD_RESET_SUGGESTION" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Es ist ein Fehler aufgetreten bei dem Versuch Ihr Passwort zurückzusetzen. Bitte versuche es später erneut." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There was an issue delivering the password reset email. Please try again." + } + } + } + }, + "FIREBASE_ACCOUNT_INVALID_CREDENTIALS" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ungültige Zugangsdaten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invalid Credentials" + } + } + } + }, + "FIREBASE_ACCOUNT_INVALID_CREDENTIALS_SUGGESTION" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitte überprüfe die eingegebene E-Mail Adresse und dein Passwort." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please verify that your email and password are correct." + } + } + } + }, + "FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anfrage fehlgeschlagen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't complete operation" + } + } + } + }, + "FIREBASE_ACCOUNT_REQUIRE_RECENT_LOGIN_ERROR_SUGGESTION" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diese Anfrage is sicherheitsrelevant und erfordert einen kürzlichen Login." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is a security relevant operation that requires a recent login." + } + } + } + }, + "FIREBASE_ACCOUNT_SETUP_ERROR" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anfrage fehlgeschlagen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not complete account operation" + } + } + } + }, + "FIREBASE_ACCOUNT_SETUP_ERROR_SUGGESTION" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bei der Anfrage für dein Benutzerkonto ist ein Fehler aufgetreten. Bitte versuche es später erneut." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There was an internal error when trying to perform the account operation. Please try again later." + } + } + } + }, + "FIREBASE_ACCOUNT_SIGN_IN_ERROR" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicht eingeloggt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not signed in" + } + } + } + }, + "FIREBASE_ACCOUNT_SIGN_IN_ERROR_SUGGESTION" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Anfrage konnte nicht ausgeführt werden weil kein verknüpftes Benutzerkonto gefunden wurde." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't complete this operation as there is no current user account." + } + } + } + }, + "FIREBASE_ACCOUNT_UNKNOWN" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anfrage fehlgeschlagen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed account operation" + } + } + } + }, + "FIREBASE_ACCOUNT_UNKNOWN_SUGGESTION" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitte überprüfen deine Internet Verbindung und versuche es erneut." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please check your internet connection and try again." + } + } + } + }, + "FIREBASE_ACCOUNT_WEAK_PASSWORD" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schwaches Passwort" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weak Password" + } + } + } + }, + "FIREBASE_ACCOUNT_WEAK_PASSWORD_SUGGESTION" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitte gebe ein stärkeres Password ein." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please choose a safer password." + } + } + } + }, + "FIREBASE_APPLE_FAILED" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mit Apple anmelden ist fehlgeschlagen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in with Apple failed" + } + } + } + }, + "FIREBASE_APPLE_FAILED_SUGGESTION" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wir hatten Probleme dich mit Apple anzumelden. Bitte versuche es später erneut." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "We had issues completing your Sign in with Apple request. Please try again later." + } + } + } + }, + "FIREBASE_EMAIL_AND_PASSWORD" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "E-Mail und Password" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "E-Mail and Password" + } + } + } + }, + "FIREBASE_IDENTITY_PROVIDER" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Single Sign-On" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Single Sign-On" + } + } + } + }, + "Login" : { + + }, + "OAuth Credential" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "OAuth Berechtigung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OAuth Credential" + } + } + } + }, + "Please enter your password for %@." : { + + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings.license b/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings.license new file mode 100644 index 0000000..2942eaf --- /dev/null +++ b/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings.license @@ -0,0 +1,5 @@ +This source file is part of the Stanford Spezi Template Application project + +SPDX-FileCopyrightText: 2023 Stanford University + +SPDX-License-Identifier: MIT diff --git a/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings b/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings deleted file mode 100644 index 1264c8f..0000000 --- a/Sources/SpeziFirebaseAccount/Resources/de.lproj/Localizable.strings +++ /dev/null @@ -1,43 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -"FIREBASE_EMAIL_AND_PASSWORD" = "E-Mail und Password"; -"FIREBASE_IDENTITY_PROVIDER" = "Single Sign-On"; - -// MARK: ERRORS -"FIREBASE_ACCOUNT_DEFAULT_PASSWORD_RULE_ERROR %lld" = "Dein Passwort muss mindestens %lld Zeichen lang sein."; - -"FIREBASE_ACCOUNT_ERROR_INVALID_EMAIL" = "Inkorrekte E-Mail Adresse"; -"FIREBASE_ACCOUNT_ERROR_INVALID_EMAIL_SUGGESTION" = "Bitte gebe eine korrekte E-Mail Adresse ein."; - -"FIREBASE_ACCOUNT_ALREADY_IN_USE" = "Benutzerkonto existiert bereits"; -"FIREBASE_ACCOUNT_ALREADY_IN_USE_SUGGESTION" = "Ein Benutzerkonto mit dieser E-Mail Adresse existiert bereits. Bitte loggen dich mit dem Account ein."; - -"FIREBASE_ACCOUNT_WEAK_PASSWORD" = "Schwaches Passwort"; -"FIREBASE_ACCOUNT_WEAK_PASSWORD_SUGGESTION" = "Bitte gebe ein stärkeres Password ein."; - -"FIREBASE_ACCOUNT_INVALID_CREDENTIALS" = "Ungültige Zugangsdaten"; -"FIREBASE_ACCOUNT_INVALID_CREDENTIALS_SUGGESTION" = "Bitte überprüfe die eingegebene E-Mail Adresse und dein Passwort."; - -"FIREBASE_ACCOUNT_FAILED_PASSWORD_RESET" = "Zurücksetzten des Passworts fehlgeschlagen"; -"FIREBASE_ACCOUNT_FAILED_PASSWORD_RESET_SUGGESTION" = "Es ist ein Fehler aufgetreten bei dem Versuch Ihr Passwort zurückzusetzen. Bitte versuche es später erneut."; - -"FIREBASE_ACCOUNT_SETUP_ERROR" = "Anfrage fehlgeschlagen"; -"FIREBASE_ACCOUNT_SETUP_ERROR_SUGGESTION" = "Bei der Anfrage für dein Benutzerkonto ist ein Fehler aufgetreten. Bitte versuche es später 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_APPLE_FAILED" = "Mit Apple anmelden ist fehlgeschlagen"; -"FIREBASE_APPLE_FAILED_SUGGESTION" = "Wir hatten Probleme dich mit Apple anzumelden. Bitte versuche es später erneut."; - -"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 deleted file mode 100644 index 9851fcb..0000000 --- a/Sources/SpeziFirebaseAccount/Resources/en.lproj/Localizable.strings +++ /dev/null @@ -1,43 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -"FIREBASE_EMAIL_AND_PASSWORD" = "E-Mail and Password"; -"FIREBASE_IDENTITY_PROVIDER" = "Single Sign-On"; - -// MARK: ERRORS -"FIREBASE_ACCOUNT_DEFAULT_PASSWORD_RULE_ERROR %lld" = "Your password must be at least %lld characters long."; - -"FIREBASE_ACCOUNT_ERROR_INVALID_EMAIL" = "Invalid email address"; -"FIREBASE_ACCOUNT_ERROR_INVALID_EMAIL_SUGGESTION" = "Please enter a valid email address."; - -"FIREBASE_ACCOUNT_ALREADY_IN_USE" = "Account already exists"; -"FIREBASE_ACCOUNT_ALREADY_IN_USE_SUGGESTION" = "An account with this email already exists. Please login using your email and password."; - -"FIREBASE_ACCOUNT_WEAK_PASSWORD" = "Weak Password"; -"FIREBASE_ACCOUNT_WEAK_PASSWORD_SUGGESTION" = "Please choose a safer password."; - -"FIREBASE_ACCOUNT_INVALID_CREDENTIALS" = "Invalid Credentials"; -"FIREBASE_ACCOUNT_INVALID_CREDENTIALS_SUGGESTION" = "Please verify that your email and password are correct."; - -"FIREBASE_ACCOUNT_FAILED_PASSWORD_RESET" = "Failed to reset password"; -"FIREBASE_ACCOUNT_FAILED_PASSWORD_RESET_SUGGESTION" = "There was an issue delivering the password reset email. Please try again."; - -"FIREBASE_ACCOUNT_SETUP_ERROR" = "Could not complete account operation"; -"FIREBASE_ACCOUNT_SETUP_ERROR_SUGGESTION" = "There was an internal error when trying to perform the account operation. Please try again later."; - -"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_APPLE_FAILED" = "Sign in with Apple failed"; -"FIREBASE_APPLE_FAILED_SUGGESTION" = "We had issues completing your Sign in with Apple request. Please try again later."; - -"FIREBASE_ACCOUNT_UNKNOWN" = "Failed account operation"; -"FIREBASE_ACCOUNT_UNKNOWN_SUGGESTION" = "Please check your internet connection and try again."; diff --git a/Sources/SpeziFirebaseAccount/Views/FirebaseAccountModifier.swift b/Sources/SpeziFirebaseAccount/Views/FirebaseAccountModifier.swift index 7ee54cc..ee5fc7b 100644 --- a/Sources/SpeziFirebaseAccount/Views/FirebaseAccountModifier.swift +++ b/Sources/SpeziFirebaseAccount/Views/FirebaseAccountModifier.swift @@ -8,70 +8,27 @@ import AuthenticationServices import OSLog -import SpeziAccount import SwiftUI struct FirebaseAccountModifier: ViewModifier { static let logger = Logger(subsystem: "edu.stanford.spezi.firebase", category: "FirebaseAccount") - private let enable: Bool - - @EnvironmentObject private var account: Account - @Environment(\.authorizationController) private var authorizationController + @Environment(FirebaseAccountModel.self) + private var firebaseModel - init(_ enable: Bool) { - self.enable = enable - } + init() {} - func body(content: Content) -> some View { - if enable { - content - .task { - Self.logger.debug("Looking at \(account.registeredAccountServices.count) account services to inject authorization controller ...") - for service in account.registeredAccountServices { - guard let firebaseService = service.castFirebaseAccountService() else { - continue - } - - Self.logger.debug("Injecting authorization controller into \(type(of: firebaseService))") - await firebaseService.inject(authorizationController: authorizationController) - } - } - } else { - content - } - } -} - -extension AccountService { - fileprivate func castFirebaseAccountService() -> (any FirebaseAccountService)? { - if let firebaseService = self as? any FirebaseAccountService { - return firebaseService - } else if let standardBacked = self as? any _StandardBacked, - let firebaseService = standardBacked.underlyingService as? any FirebaseAccountService { - return firebaseService - } else { - return nil - } - } -} - - -extension View { - /// Configure FirebaseAccount for your App. - /// - /// This modifier is currently required to be placed on the global App level, such that FirebaseAccount can - /// access the SwiftUI environment. - /// - /// - Note: If not used, this will affect the functionality of the Firebase Single Sign-On Provider. - /// - Parameter enable: Flag indicating if the account module is enabled. - public func firebaseAccount(_ enable: Bool = true) -> some View { - modifier(FirebaseAccountModifier(enable)) + func body(content: Content) -> some View { + content + .task { + firebaseModel.authorizationController = authorizationController + Self.logger.debug("Retrieved the authorization controller from the environment!") + } } } diff --git a/Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift b/Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift new file mode 100644 index 0000000..a21215f --- /dev/null +++ b/Sources/SpeziFirebaseAccount/Views/ReauthenticationAlertModifier.swift @@ -0,0 +1,84 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziAccount +import SpeziValidation +import SpeziViews +import SwiftUI + + +struct ReauthenticationAlertModifier: ViewModifier { + @Environment(FirebaseAccountModel.self) + private var firebaseModel: FirebaseAccountModel + + + @ValidationState private var validation + + @State private var password: String = "" + + + private var isPresented: Binding { + Binding { + firebaseModel.isPresentingReauthentication + } set: { newValue in + firebaseModel.isPresentingReauthentication = newValue + } + } + + private var context: ReauthenticationContext? { + firebaseModel.reauthenticationContext + } + + + func body(content: Content) -> some View { + content + .alert(Text("Authentication Required", bundle: .module), isPresented: isPresented, presenting: context) { context in + SecureField(text: $password) { + Text(PasswordFieldType.password.localizedStringResource) + } + .textContentType(.newPassword) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .validate(input: password, rules: .nonEmpty) + .receiveValidation(in: $validation) + + Button(role: .cancel, action: { + context.continuation.resume(returning: .cancelled) + }) { + Text("Cancel", bundle: .module) + } + + Button(action: { + guard validation.validateSubviews() else { + context.continuation.resume(returning: .cancelled) + return + } + context.continuation.resume(returning: .password(password)) + }) { + Text("Login", bundle: .module) + } + } message: { context in + Text("Please enter your password for \(context.userId).") + } + } +} + + +#if DEBUG +#Preview { + let model = FirebaseAccountModel() + + return Text(verbatim: "") + .modifier(ReauthenticationAlertModifier()) + .environment(model) + .task { + let password = await model.reauthenticateUser(userId: "lelandstandford@stanford.edu") + print("Password: \(password)") + } +} +#endif diff --git a/Sources/SpeziFirebaseConfiguration/ConfigureFirebaseApp.swift b/Sources/SpeziFirebaseConfiguration/ConfigureFirebaseApp.swift index e99c4ff..7007fe6 100644 --- a/Sources/SpeziFirebaseConfiguration/ConfigureFirebaseApp.swift +++ b/Sources/SpeziFirebaseConfiguration/ConfigureFirebaseApp.swift @@ -22,7 +22,7 @@ import Spezi /// // ... /// } /// ``` -public final class ConfigureFirebaseApp: Component, DefaultInitializable { +public final class ConfigureFirebaseApp: Module, DefaultInitializable { public init() {} diff --git a/Sources/SpeziFirebaseStorage/FirebaseStorageConfiguration.swift b/Sources/SpeziFirebaseStorage/FirebaseStorageConfiguration.swift index 419a654..0ed6bd8 100644 --- a/Sources/SpeziFirebaseStorage/FirebaseStorageConfiguration.swift +++ b/Sources/SpeziFirebaseStorage/FirebaseStorageConfiguration.swift @@ -24,7 +24,7 @@ import SpeziFirebaseConfiguration /// } /// } /// ``` -public final class FirebaseStorageConfiguration: Component, DefaultInitializable { +public final class FirebaseStorageConfiguration: Module, DefaultInitializable { @Dependency private var configureFirebaseApp: ConfigureFirebaseApp private let emulatorSettings: (host: String, port: Int)? diff --git a/Sources/SpeziFirestore/Resources/Localizable.xcstrings b/Sources/SpeziFirestore/Resources/Localizable.xcstrings new file mode 100644 index 0000000..21c9e57 --- /dev/null +++ b/Sources/SpeziFirestore/Resources/Localizable.xcstrings @@ -0,0 +1,310 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "FIRESTORE_ERROR_ABORTED" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Vorgang wurde abgebrochen, typischerweise aufgrund eines Nebenläufigkeitsproblems wie Transaktionsabbrüchen usw." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The operation was aborted, typically due to a concurrency issue like transaction aborts, etc." + } + } + } + }, + "FIRESTORE_ERROR_ALREADYEXISTS" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ein Dokument, das wir erstellen wollten, existiert bereits." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Some document that we attempted to create already exists." + } + } + } + }, + "FIRESTORE_ERROR_CANCELLED" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Vorgang wurde abgebrochen (typischerweise vom Aufrufer)." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The operation was cancelled (typically by the caller)." + } + } + } + }, + "FIRESTORE_ERROR_DATALOSS" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unwiederbringlicher Datenverlust oder Beschädigung." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unrecoverable data loss or corruption." + } + } + } + }, + "FIRESTORE_ERROR_DEADLINEEXCEEDED" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Frist ist abgelaufen, bevor der Vorgang abgeschlossen werden konnte. Bei Vorgängen, die den Systemzustand ändern, kann dieser Fehler zurückgegeben werden, auch wenn der Vorgang erfolgreich abgeschlossen wurde. Zum Beispiel könnte eine erfolgreiche Antwort von einem Server so lange verzögert worden sein, dass die Frist abgelaufen ist." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deadline expired before operation could complete. For operations that change the state of the system, this error may be returned even if the operation has completed successfully. For example, a successful response from a server could have been delayed long enough for the deadline to expire." + } + } + } + }, + "FIRESTORE_ERROR_DECODINGFIELDCONFLICT %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Feldkonflikt während der Dekodierung: %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Field conflict during the decoding: %@" + } + } + } + }, + "FIRESTORE_ERROR_DECODINGNOTSUPPORTED %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dekodierung wird nicht unterstützt: %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Decoding is not supported: %@" + } + } + } + }, + "FIRESTORE_ERROR_ENCODINGNOTSUPPORTED %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kodierung wird nicht unterstützt: %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Encoding is not supported: %@" + } + } + } + }, + "FIRESTORE_ERROR_FAILEDPRECONDITION" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Vorgang wurde abgelehnt, weil das System nicht im für die Ausführung des Vorgangs erforderlichen Zustand ist." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Operation was rejected because the system is not in a state required for the operation's execution." + } + } + } + }, + "FIRESTORE_ERROR_INTERNAL" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Interne Fehler. Das bedeutet, dass einige von dem zugrunde liegenden System erwartete Invarianten verletzt wurden. Wenn Sie einen dieser Fehler sehen, ist etwas sehr kaputt." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Internal errors. Means some invariants expected by underlying system has been broken. If you see one of these errors, something is very broken." + } + } + } + }, + "FIRESTORE_ERROR_INVALIDARGUMENT" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Client hat ein ungültiges Argument angegeben. Beachten Sie, dass dies sich von FailedPrecondition unterscheidet. InvalidArgument weist auf Argumente hin, die unabhängig vom Systemzustand problematisch sind (z. B. ein ungültiger Feldname)." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Client specified an invalid argument. Note that this differs from FailedPrecondition. InvalidArgument indicates arguments that are problematic regardless of the state of the system (e.g., an invalid field name)." + } + } + } + }, + "FIRESTORE_ERROR_NOTFOUND" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ein angefordertes Dokument wurde nicht gefunden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Some requested document was not found." + } + } + } + }, + "FIRESTORE_ERROR_OUTOFRANGE" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Vorgang wurde außerhalb des gültigen Bereichs versucht." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Operation was attempted past the valid range." + } + } + } + }, + "FIRESTORE_ERROR_PERMISSIONDENIED" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Anrufer hat keine Berechtigung, den angegebenen Vorgang auszuführen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The caller does not have permission to execute the specified operation." + } + } + } + }, + "FIRESTORE_ERROR_RESOURCEEXHAUSTED" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eine Ressource ist erschöpft, vielleicht ein Benutzerkontingent oder vielleicht ist das gesamte Dateisystem voll." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Some resource has been exhausted, perhaps a per-user quota, or perhaps the entire file system is out of space." + } + } + } + }, + "FIRESTORE_ERROR_UNAUTHENTICATED" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Anfrage verfügt nicht über gültige Authentifizierungsdaten für den Vorgang." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The request does not have valid authentication credentials for the operation." + } + } + } + }, + "FIRESTORE_ERROR_UNAVAILABLE" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Dienst ist derzeit nicht verfügbar. Dies ist höchstwahrscheinlich ein vorübergehender Zustand und kann durch erneutes Versuchen mit einer Verzögerung korrigiert werden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The service is currently unavailable. This is a most likely a transient condition and may be corrected by retrying with a backoff." + } + } + } + }, + "FIRESTORE_ERROR_UNIMPLEMENTED" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Vorgang ist nicht implementiert oder nicht aktiviert/unterstützt." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Operation is not implemented or not supported/enabled." + } + } + } + }, + "FIRESTORE_ERROR_UNKNOWN" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unbekannter Fehler oder ein Fehler aus einem anderen Fehlerdomäne." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown error or an error from a different error domain." + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Sources/SpeziFirestore/Resources/Localizable.xcstrings.license b/Sources/SpeziFirestore/Resources/Localizable.xcstrings.license new file mode 100644 index 0000000..2942eaf --- /dev/null +++ b/Sources/SpeziFirestore/Resources/Localizable.xcstrings.license @@ -0,0 +1,5 @@ +This source file is part of the Stanford Spezi Template Application project + +SPDX-FileCopyrightText: 2023 Stanford University + +SPDX-License-Identifier: MIT diff --git a/Sources/SpeziFirestore/Resources/de.lproj/Localizable.strings b/Sources/SpeziFirestore/Resources/de.lproj/Localizable.strings deleted file mode 100644 index 9859125..0000000 --- a/Sources/SpeziFirestore/Resources/de.lproj/Localizable.strings +++ /dev/null @@ -1,30 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - - -// MARK: ERRORS - -"FIRESTORE_ERROR_CANCELLED" = "Der Vorgang wurde abgebrochen (typischerweise vom Aufrufer)."; -"FIRESTORE_ERROR_INVALIDARGUMENT" = "Der Client hat ein ungültiges Argument angegeben. Beachten Sie, dass dies sich von FailedPrecondition unterscheidet. InvalidArgument weist auf Argumente hin, die unabhängig vom Systemzustand problematisch sind (z. B. ein ungültiger Feldname)."; -"FIRESTORE_ERROR_DEADLINEEXCEEDED" = "Die Frist ist abgelaufen, bevor der Vorgang abgeschlossen werden konnte. Bei Vorgängen, die den Systemzustand ändern, kann dieser Fehler zurückgegeben werden, auch wenn der Vorgang erfolgreich abgeschlossen wurde. Zum Beispiel könnte eine erfolgreiche Antwort von einem Server so lange verzögert worden sein, dass die Frist abgelaufen ist."; -"FIRESTORE_ERROR_NOTFOUND" = "Ein angefordertes Dokument wurde nicht gefunden."; -"FIRESTORE_ERROR_ALREADYEXISTS" = "Ein Dokument, das wir erstellen wollten, existiert bereits."; -"FIRESTORE_ERROR_PERMISSIONDENIED" = "Der Anrufer hat keine Berechtigung, den angegebenen Vorgang auszuführen."; -"FIRESTORE_ERROR_RESOURCEEXHAUSTED" = "Eine Ressource ist erschöpft, vielleicht ein Benutzerkontingent oder vielleicht ist das gesamte Dateisystem voll."; -"FIRESTORE_ERROR_FAILEDPRECONDITION" = "Der Vorgang wurde abgelehnt, weil das System nicht im für die Ausführung des Vorgangs erforderlichen Zustand ist."; -"FIRESTORE_ERROR_ABORTED" = "Der Vorgang wurde abgebrochen, typischerweise aufgrund eines Nebenläufigkeitsproblems wie Transaktionsabbrüchen usw."; -"FIRESTORE_ERROR_OUTOFRANGE" = "Der Vorgang wurde außerhalb des gültigen Bereichs versucht."; -"FIRESTORE_ERROR_UNIMPLEMENTED" = "Der Vorgang ist nicht implementiert oder nicht aktiviert/unterstützt."; -"FIRESTORE_ERROR_INTERNAL" = "Interne Fehler. Das bedeutet, dass einige von dem zugrunde liegenden System erwartete Invarianten verletzt wurden. Wenn Sie einen dieser Fehler sehen, ist etwas sehr kaputt."; -"FIRESTORE_ERROR_UNAVAILABLE" = "Der Dienst ist derzeit nicht verfügbar. Dies ist höchstwahrscheinlich ein vorübergehender Zustand und kann durch erneutes Versuchen mit einer Verzögerung korrigiert werden."; -"FIRESTORE_ERROR_DATALOSS" = "Unwiederbringlicher Datenverlust oder Beschädigung."; -"FIRESTORE_ERROR_UNAUTHENTICATED" = "Die Anfrage verfügt nicht über gültige Authentifizierungsdaten für den Vorgang."; -"FIRESTORE_ERROR_DECODINGNOTSUPPORTED %@" = "Dekodierung wird nicht unterstützt: %@"; -"FIRESTORE_ERROR_DECODINGFIELDCONFLICT %@" = "Feldkonflikt während der Dekodierung: %@"; -"FIRESTORE_ERROR_ENCODINGNOTSUPPORTED %@" = "Kodierung wird nicht unterstützt: %@"; -"FIRESTORE_ERROR_UNKNOWN" = "Unbekannter Fehler oder ein Fehler aus einem anderen Fehlerdomäne."; diff --git a/Sources/SpeziFirestore/Resources/en.lproj/Localizable.strings b/Sources/SpeziFirestore/Resources/en.lproj/Localizable.strings deleted file mode 100644 index 38b0e5a..0000000 --- a/Sources/SpeziFirestore/Resources/en.lproj/Localizable.strings +++ /dev/null @@ -1,30 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - - -// MARK: ERRORS - -"FIRESTORE_ERROR_CANCELLED" = "The operation was cancelled (typically by the caller)."; -"FIRESTORE_ERROR_INVALIDARGUMENT" = "Client specified an invalid argument. Note that this differs from FailedPrecondition. InvalidArgument indicates arguments that are problematic regardless of the state of the system (e.g., an invalid field name)."; -"FIRESTORE_ERROR_DEADLINEEXCEEDED" = "Deadline expired before operation could complete. For operations that change the state of the system, this error may be returned even if the operation has completed successfully. For example, a successful response from a server could have been delayed long enough for the deadline to expire."; -"FIRESTORE_ERROR_NOTFOUND" = "Some requested document was not found."; -"FIRESTORE_ERROR_ALREADYEXISTS" = "Some document that we attempted to create already exists."; -"FIRESTORE_ERROR_PERMISSIONDENIED" = "The caller does not have permission to execute the specified operation."; -"FIRESTORE_ERROR_RESOURCEEXHAUSTED" = "Some resource has been exhausted, perhaps a per-user quota, or perhaps the entire file system is out of space."; -"FIRESTORE_ERROR_FAILEDPRECONDITION" = "Operation was rejected because the system is not in a state required for the operation's execution."; -"FIRESTORE_ERROR_ABORTED" = "The operation was aborted, typically due to a concurrency issue like transaction aborts, etc."; -"FIRESTORE_ERROR_OUTOFRANGE" = "Operation was attempted past the valid range."; -"FIRESTORE_ERROR_UNIMPLEMENTED" = "Operation is not implemented or not supported/enabled."; -"FIRESTORE_ERROR_INTERNAL" = "Internal errors. Means some invariants expected by underlying system has been broken. If you see one of these errors, something is very broken."; -"FIRESTORE_ERROR_UNAVAILABLE" = "The service is currently unavailable. This is a most likely a transient condition and may be corrected by retrying with a backoff."; -"FIRESTORE_ERROR_DATALOSS" = "Unrecoverable data loss or corruption."; -"FIRESTORE_ERROR_UNAUTHENTICATED" = "The request does not have valid authentication credentials for the operation."; -"FIRESTORE_ERROR_DECODINGNOTSUPPORTED %@" = "Decoding is not supported: %@"; -"FIRESTORE_ERROR_DECODINGFIELDCONFLICT %@" = "Field conflict during the decoding: %@"; -"FIRESTORE_ERROR_ENCODINGNOTSUPPORTED %@" = "Encoding is not supported: %@"; -"FIRESTORE_ERROR_UNKNOWN" = "Unknown error or an error from a different error domain."; diff --git a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift index f667969..7ff45fd 100644 --- a/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift +++ b/Tests/UITests/TestApp/FirebaseAccountTests/FirebaseAccountTestsView.swift @@ -10,12 +10,14 @@ import FirebaseAuth import Spezi import SpeziAccount import SpeziFirebaseAccount +import SpeziPersonalInfo import SpeziViews import SwiftUI struct FirebaseAccountTestsView: View { - @EnvironmentObject var account: Account + @Environment(Account.self) + var account @State var viewState: ViewState = .idle @@ -59,7 +61,6 @@ struct FirebaseAccountTestsView: View { } } } - .firebaseAccount() } diff --git a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift index c885d5a..c640289 100644 --- a/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseAccountTests.swift @@ -18,7 +18,9 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo @MainActor override func setUp() async throws { try await super.setUp() - + + continueAfterFailure = false + try disablePasswordAutofill() try await FirebaseClient.deleteAllAccounts() @@ -183,6 +185,12 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo XCTAssertTrue(XCUIApplication().alerts[alert].waitForExistence(timeout: 6.0)) XCUIApplication().alerts[alert].scrollViews.otherElements.buttons["Delete"].tap() + XCTAssertTrue(app.alerts["Authentication Required"].waitForExistence(timeout: 2.0)) + XCTAssertTrue(app.alerts["Authentication Required"].secureTextFields["Password"].waitForExistence(timeout: 0.5)) + app.typeText("TestPassword") // the password field has focus already + XCTAssertTrue(app.alerts["Authentication Required"].buttons["Login"].waitForExistence(timeout: 0.5)) + app.alerts["Authentication Required"].buttons["Login"].tap() + sleep(2) let accountsNew = try await FirebaseClient.getAllAccounts() XCTAssertEqual(accountsNew, []) @@ -234,6 +242,13 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo try app.textFields["E-Mail Address"].enter(value: "de", checkIfTextWasEnteredCorrectly: false) app.buttons["Done"].tap() + + XCTAssertTrue(app.alerts["Authentication Required"].waitForExistence(timeout: 2.0)) + XCTAssertTrue(app.alerts["Authentication Required"].secureTextFields["Password"].waitForExistence(timeout: 0.5)) + app.typeText("TestPassword") // the password field has focus already + XCTAssertTrue(app.alerts["Authentication Required"].buttons["Login"].waitForExistence(timeout: 0.5)) + app.alerts["Authentication Required"].buttons["Login"].tap() + sleep(3) XCTAssertTrue(app.staticTexts["test@username.de"].waitForExistence(timeout: 5.0)) @@ -242,9 +257,8 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo XCTAssertEqual(newAccounts, [FirestoreAccount(email: "test@username.de", displayName: "Username Test1")]) } - @MainActor - func testPasswordChange() async throws { + private func passwordChangeBase() async throws { try await FirebaseClient.createAccount(email: "test@username.edu", password: "TestPassword", displayName: "Username Test") let accounts = try await FirebaseClient.getAllAccounts() @@ -281,6 +295,21 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo app.dismissKeyboard() app.buttons["Done"].tap() + } + + @MainActor + func testPasswordChange() async throws { + try await passwordChangeBase() + + let app = XCUIApplication() + + + XCTAssertTrue(app.alerts["Authentication Required"].waitForExistence(timeout: 2.0)) + XCTAssertTrue(app.alerts["Authentication Required"].secureTextFields["Password"].waitForExistence(timeout: 0.5)) + app.typeText("TestPassword") // the password field has focus already + XCTAssertTrue(app.alerts["Authentication Required"].buttons["Login"].waitForExistence(timeout: 0.5)) + app.alerts["Authentication Required"].buttons["Login"].tap() + sleep(1) app.navigationBars.buttons["Account Overview"].tap() // back button sleep(1) @@ -291,7 +320,46 @@ final class FirebaseAccountTests: XCTestCase { // swiftlint:disable:this type_bo try app.login(username: "test@username.edu", password: "1234567890", close: false) XCTAssertTrue(app.staticTexts["Username Test"].waitForExistence(timeout: 6.0)) } - + + @MainActor + func testPasswordChangeWrong() async throws { + try await passwordChangeBase() + + let app = XCUIApplication() + + + XCTAssertTrue(app.alerts["Authentication Required"].waitForExistence(timeout: 2.0)) + XCTAssertTrue(app.alerts["Authentication Required"].secureTextFields["Password"].waitForExistence(timeout: 0.5)) + app.typeText("Wrong!") // the password field has focus already + XCTAssertTrue(app.alerts["Authentication Required"].buttons["Login"].waitForExistence(timeout: 0.5)) + app.alerts["Authentication Required"].buttons["Login"].tap() + + + XCTAssertTrue(app.alerts["Invalid Credentials"].waitForExistence(timeout: 2.0)) + } + + @MainActor + func testPasswordChangeCancel() async throws { + try await passwordChangeBase() + + let app = XCUIApplication() + + + XCTAssertTrue(app.alerts["Authentication Required"].waitForExistence(timeout: 2.0)) + XCTAssertTrue(app.alerts["Authentication Required"].buttons["Cancel"].waitForExistence(timeout: 0.5)) + app.alerts["Authentication Required"].buttons["Cancel"].tap() + + sleep(1) + app.navigationBars.buttons["Account Overview"].tap() // back button + sleep(1) + app.buttons["Close"].tap() + sleep(1) + app.buttons["Logout"].tap() // we tap the custom button to be lest dependent on the other tests and not deal with the alert + + try app.login(username: "test@username.edu", password: "TestPassword", close: false) // login with previous password! + XCTAssertTrue(app.staticTexts["Username Test"].waitForExistence(timeout: 6.0)) + } + @MainActor func testPasswordReset() async throws { let app = XCUIApplication() @@ -440,4 +508,4 @@ extension XCUIApplication { sleep(3) buttons["Close"].tap() } -} +} // swiftlint:disable:this file_length diff --git a/Tests/UITests/TestAppUITests/FirebaseStorageTests.swift b/Tests/UITests/TestAppUITests/FirebaseStorageTests.swift index d9c9507..1638cee 100644 --- a/Tests/UITests/TestAppUITests/FirebaseStorageTests.swift +++ b/Tests/UITests/TestAppUITests/FirebaseStorageTests.swift @@ -24,7 +24,9 @@ final class FirebaseStorageTests: XCTestCase { @MainActor override func setUp() async throws { try await super.setUp() - + + continueAfterFailure = false + try await deleteAllFiles() try await Task.sleep(for: .seconds(0.5)) } diff --git a/Tests/UITests/TestAppUITests/FirestoreDataStorageTests.swift b/Tests/UITests/TestAppUITests/FirestoreDataStorageTests.swift index 06ae04e..85da840 100644 --- a/Tests/UITests/TestAppUITests/FirestoreDataStorageTests.swift +++ b/Tests/UITests/TestAppUITests/FirestoreDataStorageTests.swift @@ -49,7 +49,9 @@ final class FirestoreDataStorageTests: XCTestCase { @MainActor override func setUp() async throws { try await super.setUp() - + + continueAfterFailure = false + try await deleteAllDocuments() try await Task.sleep(for: .seconds(0.5)) } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index d3a8844..1d2732e 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -9,8 +9,6 @@ /* Begin PBXBuildFile section */ 2F148C00298BB15900031B7F /* FirebaseAccountTestsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F148BFF298BB15900031B7F /* FirebaseAccountTestsView.swift */; }; 2F2E4B8429749C5900FF710F /* FirestoreDataStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F2E4B8329749C5900FF710F /* FirestoreDataStorageTests.swift */; }; - 2F2E8EFC29E7366200D439B7 /* SpeziAccount in Frameworks */ = {isa = PBXBuildFile; productRef = 2F2E8EFB29E7366200D439B7 /* SpeziAccount */; }; - 2F2E8EFF29E7369B00D439B7 /* SpeziViews in Frameworks */ = {isa = PBXBuildFile; productRef = 2F2E8EFE29E7369B00D439B7 /* SpeziViews */; }; 2F6D139A28F5F386007C25D6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2F6D139928F5F386007C25D6 /* Assets.xcassets */; }; 2F746D9F29962B2A00BF54FE /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 2F746D9E29962B2A00BF54FE /* XCTestExtensions */; }; 2F87F9F12953EEB400810247 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 2F87F9F02953EEB400810247 /* GoogleService-Info.plist */; }; @@ -21,7 +19,6 @@ 2FB07593299DF96E00C0B37F /* SpeziFirebaseAccount in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB07592299DF96E00C0B37F /* SpeziFirebaseAccount */; }; 2FB07595299DF96E00C0B37F /* SpeziFirebaseConfiguration in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB07594299DF96E00C0B37F /* SpeziFirebaseConfiguration */; }; 2FB07597299DF96E00C0B37F /* SpeziFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB07596299DF96E00C0B37F /* SpeziFirestore */; }; - 2FB0759D299DF96E00C0B37F /* Spezi in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB0759C299DF96E00C0B37F /* Spezi */; }; 2FE62C3D2966074F00FCBE7F /* FirestoreDataStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE62C3C2966074F00FCBE7F /* FirestoreDataStorageTests.swift */; }; 97359F642ADB27500080CB11 /* FirebaseStorageTestsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97359F632ADB27500080CB11 /* FirebaseStorageTestsView.swift */; }; 97359F662ADB286D0080CB11 /* StorageMetadata+Sendable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97359F652ADB286D0080CB11 /* StorageMetadata+Sendable.swift */; }; @@ -70,10 +67,7 @@ files = ( 2FB07595299DF96E00C0B37F /* SpeziFirebaseConfiguration in Frameworks */, 2FB07597299DF96E00C0B37F /* SpeziFirestore in Frameworks */, - 2F2E8EFF29E7369B00D439B7 /* SpeziViews in Frameworks */, - 2F2E8EFC29E7366200D439B7 /* SpeziAccount in Frameworks */, 978DFE922ADB1E1600E2B9B5 /* SpeziFirebaseStorage in Frameworks */, - 2FB0759D299DF96E00C0B37F /* Spezi in Frameworks */, 2FB07593299DF96E00C0B37F /* SpeziFirebaseAccount in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -199,9 +193,6 @@ 2FB07592299DF96E00C0B37F /* SpeziFirebaseAccount */, 2FB07594299DF96E00C0B37F /* SpeziFirebaseConfiguration */, 2FB07596299DF96E00C0B37F /* SpeziFirestore */, - 2FB0759C299DF96E00C0B37F /* Spezi */, - 2F2E8EFB29E7366200D439B7 /* SpeziAccount */, - 2F2E8EFE29E7369B00D439B7 /* SpeziViews */, 978DFE912ADB1E1600E2B9B5 /* SpeziFirebaseStorage */, ); productName = Example; @@ -259,9 +250,6 @@ mainGroup = 2F6D138928F5F384007C25D6; packageReferences = ( 2F746D9D29962B2A00BF54FE /* XCRemoteSwiftPackageReference "XCTestExtensions" */, - 2FB0758B299DF8F100C0B37F /* XCRemoteSwiftPackageReference "Spezi" */, - 2F2E8EFA29E7363A00D439B7 /* XCRemoteSwiftPackageReference "SpeziAccount" */, - 2F2E8EFD29E7369B00D439B7 /* XCRemoteSwiftPackageReference "SpeziViews" */, ); productRefGroup = 2F6D139328F5F384007C25D6 /* Products */; projectDirPath = ""; @@ -561,6 +549,125 @@ }; name = Release; }; + A94FDCE42AFC4B4C008026CE /* Test */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_TESTING_SEARCH_PATHS = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = TEST; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Test; + }; + A94FDCE52AFC4B4C008026CE /* Test */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = TestApp/TestApp.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = 637867499T; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TestApp/Info.plist; + INFOPLIST_KEY_NSHealthShareUsageDescription = "The TestApp accesses your HealthKit data to run the tests."; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.firebase.testapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Test; + }; + A94FDCE62AFC4B4C008026CE /* Test */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 637867499T; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TestAppUITests/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.firebase.testappuitests; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE = ""; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = TestApp; + }; + name = Test; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -568,6 +675,7 @@ isa = XCConfigurationList; buildConfigurations = ( 2F6D13B428F5F386007C25D6 /* Debug */, + A94FDCE42AFC4B4C008026CE /* Test */, 2F6D13B528F5F386007C25D6 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -577,6 +685,7 @@ isa = XCConfigurationList; buildConfigurations = ( 2F6D13B728F5F386007C25D6 /* Debug */, + A94FDCE52AFC4B4C008026CE /* Test */, 2F6D13B828F5F386007C25D6 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -586,6 +695,7 @@ isa = XCConfigurationList; buildConfigurations = ( 2F6D13BD28F5F386007C25D6 /* Debug */, + A94FDCE62AFC4B4C008026CE /* Test */, 2F6D13BE28F5F386007C25D6 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -594,22 +704,6 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 2F2E8EFA29E7363A00D439B7 /* XCRemoteSwiftPackageReference "SpeziAccount" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/StanfordSpezi/SpeziAccount.git"; - requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.6.1; - }; - }; - 2F2E8EFD29E7369B00D439B7 /* XCRemoteSwiftPackageReference "SpeziViews" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/StanfordSpezi/SpeziViews.git"; - requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.5.0; - }; - }; 2F746D9D29962B2A00BF54FE /* XCRemoteSwiftPackageReference "XCTestExtensions" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/StanfordSpezi/XCTestExtensions"; @@ -618,27 +712,9 @@ minimumVersion = 0.4.7; }; }; - 2FB0758B299DF8F100C0B37F /* XCRemoteSwiftPackageReference "Spezi" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/StanfordSpezi/Spezi"; - requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.7.0; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 2F2E8EFB29E7366200D439B7 /* SpeziAccount */ = { - isa = XCSwiftPackageProductDependency; - package = 2F2E8EFA29E7363A00D439B7 /* XCRemoteSwiftPackageReference "SpeziAccount" */; - productName = SpeziAccount; - }; - 2F2E8EFE29E7369B00D439B7 /* SpeziViews */ = { - isa = XCSwiftPackageProductDependency; - package = 2F2E8EFD29E7369B00D439B7 /* XCRemoteSwiftPackageReference "SpeziViews" */; - productName = SpeziViews; - }; 2F746D9E29962B2A00BF54FE /* XCTestExtensions */ = { isa = XCSwiftPackageProductDependency; package = 2F746D9D29962B2A00BF54FE /* XCRemoteSwiftPackageReference "XCTestExtensions" */; @@ -656,11 +732,6 @@ isa = XCSwiftPackageProductDependency; productName = SpeziFirestore; }; - 2FB0759C299DF96E00C0B37F /* Spezi */ = { - isa = XCSwiftPackageProductDependency; - package = 2FB0758B299DF8F100C0B37F /* XCRemoteSwiftPackageReference "Spezi" */; - productName = Spezi; - }; 978DFE912ADB1E1600E2B9B5 /* SpeziFirebaseStorage */ = { isa = XCSwiftPackageProductDependency; productName = SpeziFirebaseStorage; 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 0a0fc91..a43621c 100644 --- a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk", "state" : { - "revision" : "837d4af6ead57cec1fc38007892500d3139c7556", - "version" : "10.16.0" + "revision" : "8872dbd7d947acf757abab933da10e83c1842280", + "version" : "10.17.0" } }, { @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "56f681586ff006a7982b53dc94082eea31971acf", - "version" : "10.16.0" + "revision" : "6b332152355c372ace9966d8ee76ed191f97025e", + "version" : "10.17.0" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleUtilities.git", "state" : { - "revision" : "c38ce365d77b04a9a300c31061c5227589e5597b", - "version" : "7.11.5" + "revision" : "f6532c8d65f8308cfdf2288cbe1971a509822680", + "version" : "7.12.0" } }, { @@ -77,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/leveldb.git", "state" : { - "revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b", - "version" : "1.22.2" + "revision" : "9d108e9112aa1d65ce508facf804674546116d9c", + "version" : "1.22.3" } }, { @@ -104,17 +104,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/Spezi", "state" : { - "revision" : "e75ea4d241b6eb611ca4c1c25926097cf324ce37", - "version" : "0.7.3" + "revision" : "b82fb371ab7f0446846ae8aeb56ffac56377890a", + "version" : "0.8.0" } }, { "identity" : "speziaccount", "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordSpezi/SpeziAccount.git", + "location" : "https://github.com/StanfordSpezi/SpeziAccount", "state" : { - "revision" : "81a215080070d419140cc55bd478f2177176623c", - "version" : "0.6.1" + "revision" : "3de5633e3bdaa2bdb19f478587d7df528299477a", + "version" : "0.7.1" + } + }, + { + "identity" : "spezifoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/StanfordSpezi/SpeziFoundation.git", + "state" : { + "revision" : "683c66f922a4cfe0882c4a86a43854f613b48541", + "version" : "0.1.0" } }, { @@ -122,8 +131,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/SpeziStorage", "state" : { - "revision" : "58f79a21291da6d2d2193275486fa3c69b4f31fa", - "version" : "0.4.3" + "revision" : "e9be3c2743e462894bf56d41339b040f4060b567", + "version" : "0.5.0" } }, { @@ -131,8 +140,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/SpeziViews.git", "state" : { - "revision" : "4b7cc423fd823123d354ec1d541ca7d2e0a9d6e3", - "version" : "0.5.1" + "revision" : "5cef2980e8309b74501759cdb2cce8a1b9c34502", + "version" : "0.6.1" } }, { @@ -149,8 +158,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "7dade5ea5ab0d05a2dd28f9cc6a6ea0d50588857", - "version" : "1.25.0" + "revision" : "07f7f26ded8df9645c072f220378879c4642e063", + "version" : "1.25.1" } }, {