Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ private struct CopyKeyVisitor: AccountKeyVisitor {
/// - ``isNewUser``
/// - ``isIncomplete``
/// - ``isVerified``
/// - ``pendingUserId``
/// - ``accountServiceConfiguration``
/// - ``userIdType``
///
Expand Down
34 changes: 34 additions & 0 deletions Sources/SpeziAccount/AccountValue/Keys/PendingUserIdKey.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
28 changes: 28 additions & 0 deletions Sources/SpeziAccount/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions Sources/SpeziAccount/ViewModel/AccountDisplayModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ struct AccountDisplayModel {
}
}

var pendingUserId: String? {
accountDetails.pendingUserId
}

var accountSubheadline: String? {
if accountDetails.name != nil {
if !accountDetails.contains(AccountKeys.userId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions Sources/SpeziAccount/Views/AccountOverview/NameOverview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@


var body: some View {
Form {

Check failure on line 22 in Sources/SpeziAccount/Views/AccountOverview/NameOverview.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Closure Body Length Violation: Closure body should span 35 lines or less excluding comments and whitespace: currently spans 36 lines (closure_body_length)
let forEachWrappers = model.namesOverviewKeys(details: accountDetails)
.map { ForEachAccountKeyWrapper($0) }

Expand Down Expand Up @@ -51,6 +51,11 @@
let title = AccountKeys.name.category.categoryTitle {
Text(title)
}
} footer: {
if wrapper.accountKey == AccountKeys.userId,
let pendingUserId = accountDetails.pendingUserId {
Text("USER_ID_CHANGE_PENDING \(pendingUserId)", bundle: .module)
}
}
}
}
Expand Down
44 changes: 39 additions & 5 deletions Sources/SpeziFirebaseAccount/FirebaseAccountService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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))
}
}

Expand Down Expand Up @@ -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() {
Expand Down
24 changes: 24 additions & 0 deletions Sources/SpeziFirebaseAccount/Models/FirebaseAccountModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
32 changes: 32 additions & 0 deletions Sources/SpeziFirebaseAccount/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
24 changes: 24 additions & 0 deletions Sources/SpeziFirebaseAccount/Views/FirebaseSecurityAlert.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,6 +42,14 @@ public struct FirebaseSecurityAlert: ViewModifier {
firebaseModel.reauthenticationContext
}

@MainActor private var isEmailChangeNoticePresented: Binding<Bool> {
Binding {
firebaseModel.isPresentingEmailChangeNotice && isActive
} set: { newValue in
firebaseModel.isPresentingEmailChangeNotice = newValue
}
}

nonisolated init() {}


Expand Down Expand Up @@ -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
)
}
}
}

Expand Down
Loading