Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -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) {
Expand All @@ -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
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ public struct AuthPickerView<Content: View> {
@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?
}
Expand Down Expand Up @@ -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()
}
}

Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand All @@ -54,23 +55,31 @@ 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
}
}

private func createUserWithEmailPassword() async throws {
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
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""

Expand Down Expand Up @@ -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
}
}
}) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
}
}
}
Expand Down
Loading
Loading