diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift index ebd30228c9..35f23bffe9 100644 --- a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift @@ -20,6 +20,7 @@ import SwiftUI @MainActor public struct SignInWithAppleButton { @Environment(AuthService.self) private var authService + @Environment(\.accountConflictHandler) private var accountConflictHandler @Environment(\.reportError) private var reportError let provider: AppleProviderSwift public init(provider: AppleProviderSwift) { @@ -38,11 +39,15 @@ extension SignInWithAppleButton: View { do { _ = try await authService.signIn(provider) } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error + reportError?(error) + + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return } + + throw error } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 0d87de6ef6..d968512db9 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -136,9 +136,6 @@ public final class AuthService { public var currentMFARequired: MFARequired? private var currentMFAResolver: MultiFactorResolver? - /// Current account conflict context - observe this to handle conflicts and update backend - public private(set) var currentAccountConflict: AccountConflictContext? - // MARK: - Provider APIs private var listenerManager: AuthListenerManager? @@ -189,17 +186,12 @@ public final class AuthService { } public func updateAuthenticationState() { - reset() authenticationState = (currentUser == nil || currentUser?.isAnonymous == true) ? .unauthenticated : .authenticated } - func reset() { - currentAccountConflict = nil - } - public var shouldHandleAnonymousUpgrade: Bool { currentUser?.isAnonymous == true && configuration.shouldAutoUpgradeAnonymousUsers } @@ -823,7 +815,7 @@ public extension AuthService { ) } - /// Handles account conflict errors by creating context, storing it, and throwing structured error + /// Handles account conflict errors by creating context and throwing structured error /// - Parameters: /// - error: The error to check and handle /// - credential: The credential that caused the conflict @@ -840,10 +832,6 @@ public extension AuthService { credential: credential ) - // Store it for consumers to observe - currentAccountConflict = context - - // Throw the specific error with context throw AuthServiceError.accountConflict(context) } else { throw error diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AccountConflictModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AccountConflictModifier.swift new file mode 100644 index 0000000000..2702670d34 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AccountConflictModifier.swift @@ -0,0 +1,95 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +import SwiftUI + +/// Environment key for accessing the account conflict handler +public struct AccountConflictHandlerKey: @preconcurrency EnvironmentKey { + @MainActor public static let defaultValue: ((AccountConflictContext) -> Void)? = nil +} + +public extension EnvironmentValues { + var accountConflictHandler: ((AccountConflictContext) -> Void)? { + get { self[AccountConflictHandlerKey.self] } + set { self[AccountConflictHandlerKey.self] = newValue } + } +} + +/// View modifier that handles account conflicts at the view layer +/// Automatically resolves anonymous upgrade conflicts and stores credentials for other conflicts +@MainActor +struct AccountConflictModifier: ViewModifier { + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var pendingCredentialForLinking: AuthCredential? + + func body(content: Content) -> some View { + content + .environment(\.accountConflictHandler, handleAccountConflict) + .onChange(of: authService.authenticationState) { _, newState in + // Auto-link pending credential after successful sign-in + if newState == .authenticated { + attemptAutoLinkPendingCredential() + } + } + } + + /// Handle account conflicts - auto-resolve anonymous upgrades, store others for linking + func handleAccountConflict(_ conflict: AccountConflictContext) { + // Only auto-handle anonymous upgrade conflicts + if conflict.conflictType == .anonymousUpgradeConflict { + Task { + do { + // Sign out the anonymous user + try await authService.signOut() + + // Sign in with the new credential + _ = try await authService.signIn(credentials: conflict.credential) + } catch { + // Report error to parent view for display + reportError?(error) + } + } + } else { + // Other conflicts: store credential for potential linking after sign-in + pendingCredentialForLinking = conflict.credential + // Error modal will show for user to see and handle + } + } + + /// Attempt to link pending credential after successful sign-in + private func attemptAutoLinkPendingCredential() { + guard let credential = pendingCredentialForLinking else { return } + + Task { + do { + try await authService.linkAccounts(credentials: credential) + // Successfully linked, clear the pending credential + pendingCredentialForLinking = nil + } catch { + // Silently swallow linking errors - user is already signed in + pendingCredentialForLinking = nil + } + } + } +} + +extension View { + /// Adds account conflict handling to the view hierarchy + /// Should be applied at the NavigationStack level to handle conflicts throughout the auth flow + func accountConflictHandler() -> some View { + modifier(AccountConflictModifier()) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 9bbd79a976..e53e39f527 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -26,8 +26,6 @@ public struct AuthPickerView { @Environment(AuthService.self) private var authService private let content: () -> Content - // View-layer state for handling auto-linking flow - @State private var pendingCredentialForLinking: AuthCredential? // View-layer error state @State private var error: AlertError? } @@ -76,17 +74,8 @@ extension AuthPickerView: View { okButtonLabel: authService.string.okButtonLabel ) .interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled) - } - // View-layer logic: Handle account conflicts (auto-handle anonymous upgrade, store others for - // linking) - .onChange(of: authService.currentAccountConflict) { _, conflict in - handleAccountConflict(conflict) - } - // View-layer logic: Auto-link pending credential after successful sign-in - .onChange(of: authService.authenticationState) { _, newState in - if newState == .authenticated { - attemptAutoLinkPendingCredential() - } + // Apply account conflict handling at NavigationStack level + .accountConflictHandler() } } @@ -100,54 +89,6 @@ extension AuthPickerView: View { } } - /// View-layer logic: Handle account conflicts with type-specific behavior - private func handleAccountConflict(_ conflict: AccountConflictContext?) { - guard let conflict = conflict else { return } - - // Only auto-handle anonymous upgrade conflicts - if conflict.conflictType == .anonymousUpgradeConflict { - Task { - do { - // Sign out the anonymous user - try await authService.signOut() - - // Sign in with the new credential - _ = try await authService.signIn(credentials: conflict.credential) - - // Successfully handled - conflict is cleared automatically by reset() - } catch let caughtError { - // Show error in alert - reportError(caughtError) - } - } - } else { - // Other conflicts: store credential for potential linking after sign-in - pendingCredentialForLinking = conflict.credential - // Show error modal for user to see and handle - error = AlertError( - message: conflict.message, - underlyingError: conflict.underlyingError - ) - } - } - - /// View-layer logic: Attempt to link pending credential after successful sign-in - private func attemptAutoLinkPendingCredential() { - guard let credential = pendingCredentialForLinking else { return } - - Task { - do { - try await authService.linkAccounts(credentials: credential) - // Successfully linked, clear the pending credential - pendingCredentialForLinking = nil - } catch let caughtError { - // Show error - user is already signed in but linking failed - reportError(caughtError) - pendingCredentialForLinking = nil - } - } - } - @ToolbarContentBuilder var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index bcf015d0be..a0867a307e 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -32,6 +32,7 @@ private enum FocusableField: Hashable { @MainActor public struct EmailAuthView { @Environment(AuthService.self) private var authService + @Environment(\.accountConflictHandler) private var accountConflictHandler @Environment(\.reportError) private var reportError @State private var email = "" @@ -54,11 +55,15 @@ public struct EmailAuthView { do { _ = try await authService.signIn(email: email, password: password) } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error + reportError?(error) + + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return } + + throw error } } @@ -66,11 +71,15 @@ public struct EmailAuthView { do { _ = try await authService.createUser(email: email, password: password) } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error + reportError?(error) + + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return } + + throw error } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift index 592e05d38d..c614c7d942 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift @@ -19,6 +19,7 @@ import SwiftUI public struct EmailLinkView { @Environment(AuthService.self) private var authService + @Environment(\.accountConflictHandler) private var accountConflictHandler @Environment(\.reportError) private var reportError @State private var email = "" @State private var showModal = false @@ -94,11 +95,15 @@ extension EmailLinkView: View { do { try await authService.handleSignInLink(url: url) } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error + reportError?(error) + + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return } + + throw error } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift index 3b90934447..7e45220a87 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift @@ -20,6 +20,7 @@ import SwiftUI @MainActor struct EnterVerificationCodeView: View { @Environment(AuthService.self) private var authService + @Environment(\.accountConflictHandler) private var accountConflictHandler @Environment(\.reportError) private var reportError @State private var verificationCode: String = "" @@ -59,11 +60,15 @@ struct EnterVerificationCodeView: View { ) authService.navigator.clear() } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error + reportError?(error) + + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return } + + throw error } } }) { diff --git a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift index 0cbbc48df9..225172534b 100644 --- a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift +++ b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift @@ -22,6 +22,7 @@ import SwiftUI @MainActor public struct SignInWithFacebookButton { @Environment(AuthService.self) private var authService + @Environment(\.accountConflictHandler) private var accountConflictHandler @Environment(\.reportError) private var reportError let facebookProvider: FacebookProviderSwift @@ -41,11 +42,15 @@ extension SignInWithFacebookButton: View { do { _ = try await authService.signIn(facebookProvider) } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error + reportError?(error) + + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return } + + throw error } } } diff --git a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift index 4ff90c0899..7a7a3cc05b 100644 --- a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift +++ b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift @@ -26,6 +26,7 @@ import SwiftUI @MainActor public struct SignInWithGoogleButton { @Environment(AuthService.self) private var authService + @Environment(\.accountConflictHandler) private var accountConflictHandler @Environment(\.reportError) private var reportError let googleProvider: GoogleProviderSwift @@ -45,11 +46,15 @@ extension SignInWithGoogleButton: View { do { _ = try await authService.signIn(googleProvider) } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error + reportError?(error) + + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return } + + throw error } } } diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift index 65e0e51241..2ce8ae1158 100644 --- a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift +++ b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift @@ -20,6 +20,7 @@ import SwiftUI @MainActor public struct GenericOAuthButton { @Environment(AuthService.self) private var authService + @Environment(\.accountConflictHandler) private var accountConflictHandler @Environment(\.reportError) private var reportError let provider: OAuthProviderSwift public init(provider: OAuthProviderSwift) { @@ -48,11 +49,15 @@ extension GenericOAuthButton: View { do { _ = try await authService.signIn(provider) } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error + reportError?(error) + + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return } + + throw error } } } diff --git a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift index e398c387df..0c8d89b779 100644 --- a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift +++ b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift @@ -20,6 +20,7 @@ import SwiftUI @MainActor public struct SignInWithTwitterButton { @Environment(AuthService.self) private var authService + @Environment(\.accountConflictHandler) private var accountConflictHandler @Environment(\.reportError) private var reportError let provider: TwitterProviderSwift public init(provider: TwitterProviderSwift) { @@ -38,11 +39,15 @@ extension SignInWithTwitterButton: View { do { _ = try await authService.signIn(provider) } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error + reportError?(error) + + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return } + + throw error } } }