diff --git a/Sources/SpeziAccount/AccountValue/Collections/AccountDetails.swift b/Sources/SpeziAccount/AccountValue/Collections/AccountDetails.swift index 54725e296..1d00a81c7 100644 --- a/Sources/SpeziAccount/AccountValue/Collections/AccountDetails.swift +++ b/Sources/SpeziAccount/AccountValue/Collections/AccountDetails.swift @@ -123,6 +123,7 @@ private struct CopyKeyVisitor: AccountKeyVisitor { /// - ``isNewUser`` /// - ``isIncomplete`` /// - ``isVerified`` +/// - ``pendingUserId`` /// - ``accountServiceConfiguration`` /// - ``userIdType`` /// diff --git a/Sources/SpeziAccount/AccountValue/Keys/PendingUserIdKey.swift b/Sources/SpeziAccount/AccountValue/Keys/PendingUserIdKey.swift new file mode 100644 index 000000000..129e27cec --- /dev/null +++ b/Sources/SpeziAccount/AccountValue/Keys/PendingUserIdKey.swift @@ -0,0 +1,34 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziFoundation + + +extension AccountDetails { + private struct PendingUserIdKey: KnowledgeSource { + typealias Anchor = AccountAnchor + typealias Value = String + } + + /// A new user identifier that was requested but is still pending confirmation. + /// + /// An account service can set this property to indicate that a change of the ``userId`` was requested but did not take effect yet + /// (e.g., the user still needs to open a verification link that was sent to their new email address). + /// Views like `AccountOverview` display this information alongside the current user identifier. + /// + /// - Note: This is transient, in-memory state supplied by the account service with the rest of the account details. + /// It is generally not persisted and, therefore, might not be available across application launches. + public var pendingUserId: String? { + get { + self[PendingUserIdKey.self] + } + set { + self[PendingUserIdKey.self] = newValue + } + } +} diff --git a/Sources/SpeziAccount/Resources/Localizable.xcstrings b/Sources/SpeziAccount/Resources/Localizable.xcstrings index 8c6495455..67bd939c5 100644 --- a/Sources/SpeziAccount/Resources/Localizable.xcstrings +++ b/Sources/SpeziAccount/Resources/Localizable.xcstrings @@ -2615,6 +2615,34 @@ } } }, + "USER_ID_CHANGE_PENDING %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Änderung zu %@ muss noch bestätigt werden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The change to %@ is pending confirmation." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El cambio a %@ está pendiente de confirmación." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ändringen till %@ väntar på bekräftelse." + } + } + } + }, "USER_ID_EMAIL" : { "comment" : "The key for the localized string resource that represents the user id type \"email address\".", "isCommentAutoGenerated" : true, diff --git a/Sources/SpeziAccount/ViewModel/AccountDisplayModel.swift b/Sources/SpeziAccount/ViewModel/AccountDisplayModel.swift index e610f6a5d..48f11c06d 100644 --- a/Sources/SpeziAccount/ViewModel/AccountDisplayModel.swift +++ b/Sources/SpeziAccount/ViewModel/AccountDisplayModel.swift @@ -28,6 +28,10 @@ struct AccountDisplayModel { } } + var pendingUserId: String? { + accountDetails.pendingUserId + } + var accountSubheadline: String? { if accountDetails.name != nil { if !accountDetails.contains(AccountKeys.userId) { diff --git a/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewHeader.swift b/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewHeader.swift index 615efbf5b..324c3b1d3 100644 --- a/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewHeader.swift +++ b/Sources/SpeziAccount/Views/AccountOverview/AccountOverviewHeader.swift @@ -45,6 +45,13 @@ struct AccountOverviewHeader: View { .font(.subheadline) .foregroundColor(.secondary) } + + if let pendingUserId = model.pendingUserId { + Text("USER_ID_CHANGE_PENDING \(pendingUserId)", bundle: .module) + .font(.footnote) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } } .accessibilityElement(children: .combine) .frame(maxWidth: .infinity, alignment: .center) diff --git a/Sources/SpeziAccount/Views/AccountOverview/NameOverview.swift b/Sources/SpeziAccount/Views/AccountOverview/NameOverview.swift index fbe3e4f5d..e22ceadbb 100644 --- a/Sources/SpeziAccount/Views/AccountOverview/NameOverview.swift +++ b/Sources/SpeziAccount/Views/AccountOverview/NameOverview.swift @@ -24,34 +24,7 @@ struct NameOverview: View { .map { ForEachAccountKeyWrapper($0) } ForEach(forEachWrappers, id: \.id) { wrapper in - Section { - NavigationLink { - wrapper.accountKey.singleEditView(model: model, details: accountDetails) - .anyModifiers(account.securityRelatedModifiers.map { $0.anyViewModifier }) - } label: { - if let view = wrapper.accountKey.dataDisplayViewWithCurrentStoredValue(from: accountDetails) { - view - } else { - let name = wrapper.accountKey == AccountKeys.userId - ? accountDetails.userIdType.localizedStringResource - : wrapper.accountKey.name - - HStack { - Text(name) - .accessibilityHidden(true) - Spacer() - Text("VALUE_ADD \(name)", bundle: .module) - .foregroundColor(.secondary) - } - .accessibilityElement(children: .combine) - } - } - } header: { - if wrapper.accountKey == AccountKeys.name, - let title = AccountKeys.name.category.categoryTitle { - Text(title) - } - } + section(for: wrapper.accountKey) } } .navigationTitle(model.accountIdentifierLabel(configuration: account.configuration, accountDetails)) @@ -62,11 +35,48 @@ struct NameOverview: View { .environment(\.accountViewType, .overview(mode: .display)) } - init(model: AccountOverviewFormViewModel, details accountDetails: AccountDetails) { self.model = model self.accountDetails = accountDetails } + + + @ViewBuilder + private func section(for accountKey: any AccountKey.Type) -> some View { + Section { + NavigationLink { + accountKey.singleEditView(model: model, details: accountDetails) + .anyModifiers(account.securityRelatedModifiers.map { $0.anyViewModifier }) + } label: { + if let view = accountKey.dataDisplayViewWithCurrentStoredValue(from: accountDetails) { + view + } else { + let name = accountKey == AccountKeys.userId + ? accountDetails.userIdType.localizedStringResource + : accountKey.name + + HStack { + Text(name) + .accessibilityHidden(true) + Spacer() + Text("VALUE_ADD \(name)", bundle: .module) + .foregroundColor(.secondary) + } + .accessibilityElement(children: .combine) + } + } + } header: { + if accountKey == AccountKeys.name, + let title = AccountKeys.name.category.categoryTitle { + Text(title) + } + } footer: { + if accountKey == AccountKeys.userId, + let pendingUserId = accountDetails.pendingUserId { + Text("USER_ID_CHANGE_PENDING \(pendingUserId)", bundle: .module) + } + } + } } diff --git a/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift index 439c6b92b..dc7d014ce 100644 --- a/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift +++ b/Sources/SpeziFirebaseAccount/FirebaseAccountService.swift @@ -553,6 +553,13 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable /// Otherwise, an alert will be presented to enter the password credential. Make sure that the ``securityAlert`` modifier is injected from the point your are calling /// this method. This is automatically done with native SpeziAccount views. /// + /// - Note: Changing the userId (the account's email address) does not take effect immediately. Firebase sends a verification link to the + /// new email address and only applies the change once the user opens that link. Until then, the account details continue to report the + /// old email address and expose the new one via the `AccountDetails/pendingUserId` property, which is displayed by views like + /// `AccountOverview`. The pending state is kept in memory only and is not persisted across app launches. Once the change takes effect, + /// Firebase revokes the user's tokens on all devices; the user will be signed out and has to log in again with the new email address. + /// An alert informing the user about the verification email is presented through the ``securityAlert`` modifier. + /// /// - Throws: Throws an ``FirebaseAccountError`` if the operation fails. A ``FirebaseAccountError/notSignedIn`` is thrown if delete /// is called when no user was logged in. public func updateAccountDetails(_ modifications: AccountModifications) async throws { @@ -574,9 +581,11 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable try await mapFirebaseAccountError { if modifications.modifiedDetails.contains(AccountKeys.userId) { - logger.debug("updateEmail(to:) for user.") - try await currentUser.updateEmail(to: modifications.modifiedDetails.userId) - try await currentUser.reload() + logger.debug("sendEmailVerification(beforeUpdatingEmail:) for user.") + // `updateEmail(to:)` is deprecated and fails when email enumeration protection is enabled (the default). + // This call only sends a verification link to the new address; the email is updated once the user opens it, + // at which point Firebase revokes the user's tokens and the user has to sign in again. + try await currentUser.sendEmailVerification(beforeUpdatingEmail: modifications.modifiedDetails.userId) } if let password = modifications.modifiedDetails.password { @@ -589,6 +598,14 @@ public final class FirebaseAccountService: AccountService { // swiftlint:disable } } + if modifications.modifiedDetails.contains(AccountKeys.userId) { + // the email change is pending until the user opens the verification link; present a notice and track the + // pending state so that views can display it alongside the current email address (see `withPendingEmailChange`) + firebaseModel.presentEmailChangeNotice( + PendingEmailChange(accountId: currentUser.uid, emailAddress: modifications.modifiedDetails.userId) + ) + } + var externalModifications = modifications externalModifications.removeModifications(for: Self.supportedAccountKeys) if !externalModifications.isEmpty { @@ -745,7 +762,7 @@ extension FirebaseAccountService { let details = buildUser(user, isNewUser: consideredNewUser, mergeWith: details) logger.debug("Update user details due to updates in the externally stored account details.") - account.supplyUserDetails(details) + account.supplyUserDetails(withPendingEmailChange(details, for: user)) } } @@ -1060,7 +1077,24 @@ extension FirebaseAccountService { let isNewUser = isNewUser ?? account.details?.isNewUser ?? false let details = await buildUserQueryingStorageProvider(user: user, isNewUser: isNewUser) logger.debug("Notifying SpeziAccount with updated user details.") - account.supplyUserDetails(details) + account.supplyUserDetails(withPendingEmailChange(details, for: user)) + } + + /// Attach a pending email address change to the account details, if one exists for the user. + /// + /// The pending state is kept in memory only. It is cleared once we observe that the change took effect + /// (the user's email address matches the previously requested one). + private func withPendingEmailChange(_ details: AccountDetails, for user: User) -> AccountDetails { + guard let change = firebaseModel.pendingEmailChange, change.accountId == user.uid else { + return details + } + guard change.emailAddress != user.email else { + firebaseModel.clearPendingEmailChange(for: user.uid) + return details + } + var details = details + details.pendingUserId = change.emailAddress + return details } func notifyUserRemoval() { diff --git a/Sources/SpeziFirebaseAccount/Models/FirebaseAccountModel.swift b/Sources/SpeziFirebaseAccount/Models/FirebaseAccountModel.swift index 422474055..85b1cd8a2 100644 --- a/Sources/SpeziFirebaseAccount/Models/FirebaseAccountModel.swift +++ b/Sources/SpeziFirebaseAccount/Models/FirebaseAccountModel.swift @@ -11,6 +11,15 @@ import Observation import SwiftUI +/// An email address change that was requested but is still pending verification through the confirmation link. +struct PendingEmailChange: Equatable, Sendable { + /// The Firebase user identifier (`uid`) of the account the change was requested for. + let accountId: String + /// The new email address that is pending verification. + let emailAddress: String +} + + @Observable @MainActor class FirebaseAccountModel { @@ -19,9 +28,24 @@ class FirebaseAccountModel { var isPresentingReauthentication = false var reauthenticationContext: ReauthenticationContext? + var isPresentingEmailChangeNotice = false + private(set) var pendingEmailChange: PendingEmailChange? + nonisolated init() {} + func presentEmailChangeNotice(_ change: PendingEmailChange) { + pendingEmailChange = change + isPresentingEmailChangeNotice = true + } + + func clearPendingEmailChange(for accountId: String) { + guard pendingEmailChange?.accountId == accountId else { + return + } + pendingEmailChange = nil + } + func reauthenticateUser(userId: String) async -> ReauthenticationResult { defer { reauthenticationContext = nil diff --git a/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings b/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings index 8ce50e216..94d9d9d64 100644 --- a/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings +++ b/Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings @@ -512,6 +512,38 @@ } } } + }, + "Verify Your New Email Address" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bestätige deine neue E-Mail Adresse" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verify Your New Email Address" + } + } + } + }, + "We sent a confirmation link to %@. Your email address will change once you open the link. You may need to sign in again." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wir haben einen Bestätigungslink an %@ gesendet. Deine E-Mail Adresse ändert sich, sobald du den Link öffnest. Danach musst du dich möglicherweise erneut anmelden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "We sent a confirmation link to %@. Your email address will change once you open the link. You may need to sign in again." + } + } + } } }, "version" : "1.0" diff --git a/Sources/SpeziFirebaseAccount/Views/FirebaseSecurityAlert.swift b/Sources/SpeziFirebaseAccount/Views/FirebaseSecurityAlert.swift index b37afa98b..d06b7cd14 100644 --- a/Sources/SpeziFirebaseAccount/Views/FirebaseSecurityAlert.swift +++ b/Sources/SpeziFirebaseAccount/Views/FirebaseSecurityAlert.swift @@ -16,6 +16,9 @@ import SwiftUI /// /// The alert will request the user's password to authorize security-sensitive operations like account deletion or change of /// sensitive account details. +/// +/// It additionally presents informational alerts related to security-sensitive operations, like the notice that a +/// verification link was sent to the new email address after the user requested to change their email address. public struct FirebaseSecurityAlert: ViewModifier { @Environment(FirebaseAccountModel.self) private var firebaseModel: FirebaseAccountModel @@ -39,6 +42,14 @@ public struct FirebaseSecurityAlert: ViewModifier { firebaseModel.reauthenticationContext } + @MainActor private var isEmailChangeNoticePresented: Binding { + Binding { + firebaseModel.isPresentingEmailChangeNotice && isActive + } set: { newValue in + firebaseModel.isPresentingEmailChangeNotice = newValue + } + } + nonisolated init() {} @@ -81,6 +92,19 @@ public struct FirebaseSecurityAlert: ViewModifier { } message: { context in Text("Please enter your password for \(context.userId).") } + .alert( + Text("Verify Your New Email Address", bundle: .module), + isPresented: isEmailChangeNoticePresented, + presenting: firebaseModel.pendingEmailChange + ) { _ in + // the system provides a default OK button + } message: { change in + let email = change.emailAddress + Text( + "We sent a confirmation link to \(email). Your email address will change once you open the link. You may need to sign in again.", + bundle: .module + ) + } } }