Skip to content

Commit b634cba

Browse files
Merge pull request #1299 from firebase/merge-conflict-refactor
2 parents 7e1dcee + 6299568 commit b634cba

File tree

11 files changed

+178
-110
lines changed

11 files changed

+178
-110
lines changed

FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import SwiftUI
2020
@MainActor
2121
public struct SignInWithAppleButton {
2222
@Environment(AuthService.self) private var authService
23+
@Environment(\.accountConflictHandler) private var accountConflictHandler
2324
@Environment(\.reportError) private var reportError
2425
let provider: AppleProviderSwift
2526
public init(provider: AppleProviderSwift) {
@@ -38,11 +39,15 @@ extension SignInWithAppleButton: View {
3839
do {
3940
_ = try await authService.signIn(provider)
4041
} catch {
41-
if let errorHandler = reportError {
42-
errorHandler(error)
43-
} else {
44-
throw error
42+
reportError?(error)
43+
44+
if case let AuthServiceError.accountConflict(ctx) = error,
45+
let onConflict = accountConflictHandler {
46+
onConflict(ctx)
47+
return
4548
}
49+
50+
throw error
4651
}
4752
}
4853
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,6 @@ public final class AuthService {
136136
public var currentMFARequired: MFARequired?
137137
private var currentMFAResolver: MultiFactorResolver?
138138

139-
/// Current account conflict context - observe this to handle conflicts and update backend
140-
public private(set) var currentAccountConflict: AccountConflictContext?
141-
142139
// MARK: - Provider APIs
143140

144141
private var listenerManager: AuthListenerManager?
@@ -189,17 +186,12 @@ public final class AuthService {
189186
}
190187

191188
public func updateAuthenticationState() {
192-
reset()
193189
authenticationState =
194190
(currentUser == nil || currentUser?.isAnonymous == true)
195191
? .unauthenticated
196192
: .authenticated
197193
}
198194

199-
func reset() {
200-
currentAccountConflict = nil
201-
}
202-
203195
public var shouldHandleAnonymousUpgrade: Bool {
204196
currentUser?.isAnonymous == true && configuration.shouldAutoUpgradeAnonymousUsers
205197
}
@@ -823,7 +815,7 @@ public extension AuthService {
823815
)
824816
}
825817

826-
/// Handles account conflict errors by creating context, storing it, and throwing structured error
818+
/// Handles account conflict errors by creating context and throwing structured error
827819
/// - Parameters:
828820
/// - error: The error to check and handle
829821
/// - credential: The credential that caused the conflict
@@ -840,10 +832,6 @@ public extension AuthService {
840832
credential: credential
841833
)
842834

843-
// Store it for consumers to observe
844-
currentAccountConflict = context
845-
846-
// Throw the specific error with context
847835
throw AuthServiceError.accountConflict(context)
848836
} else {
849837
throw error
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import FirebaseAuth
16+
import SwiftUI
17+
18+
/// Environment key for accessing the account conflict handler
19+
public struct AccountConflictHandlerKey: @preconcurrency EnvironmentKey {
20+
@MainActor public static let defaultValue: ((AccountConflictContext) -> Void)? = nil
21+
}
22+
23+
public extension EnvironmentValues {
24+
var accountConflictHandler: ((AccountConflictContext) -> Void)? {
25+
get { self[AccountConflictHandlerKey.self] }
26+
set { self[AccountConflictHandlerKey.self] = newValue }
27+
}
28+
}
29+
30+
/// View modifier that handles account conflicts at the view layer
31+
/// Automatically resolves anonymous upgrade conflicts and stores credentials for other conflicts
32+
@MainActor
33+
struct AccountConflictModifier: ViewModifier {
34+
@Environment(AuthService.self) private var authService
35+
@Environment(\.reportError) private var reportError
36+
@State private var pendingCredentialForLinking: AuthCredential?
37+
38+
func body(content: Content) -> some View {
39+
content
40+
.environment(\.accountConflictHandler, handleAccountConflict)
41+
.onChange(of: authService.authenticationState) { _, newState in
42+
// Auto-link pending credential after successful sign-in
43+
if newState == .authenticated {
44+
attemptAutoLinkPendingCredential()
45+
}
46+
}
47+
}
48+
49+
/// Handle account conflicts - auto-resolve anonymous upgrades, store others for linking
50+
func handleAccountConflict(_ conflict: AccountConflictContext) {
51+
// Only auto-handle anonymous upgrade conflicts
52+
if conflict.conflictType == .anonymousUpgradeConflict {
53+
Task {
54+
do {
55+
// Sign out the anonymous user
56+
try await authService.signOut()
57+
58+
// Sign in with the new credential
59+
_ = try await authService.signIn(credentials: conflict.credential)
60+
} catch {
61+
// Report error to parent view for display
62+
reportError?(error)
63+
}
64+
}
65+
} else {
66+
// Other conflicts: store credential for potential linking after sign-in
67+
pendingCredentialForLinking = conflict.credential
68+
// Error modal will show for user to see and handle
69+
}
70+
}
71+
72+
/// Attempt to link pending credential after successful sign-in
73+
private func attemptAutoLinkPendingCredential() {
74+
guard let credential = pendingCredentialForLinking else { return }
75+
76+
Task {
77+
do {
78+
try await authService.linkAccounts(credentials: credential)
79+
// Successfully linked, clear the pending credential
80+
pendingCredentialForLinking = nil
81+
} catch {
82+
// Silently swallow linking errors - user is already signed in
83+
pendingCredentialForLinking = nil
84+
}
85+
}
86+
}
87+
}
88+
89+
extension View {
90+
/// Adds account conflict handling to the view hierarchy
91+
/// Should be applied at the NavigationStack level to handle conflicts throughout the auth flow
92+
func accountConflictHandler() -> some View {
93+
modifier(AccountConflictModifier())
94+
}
95+
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift

Lines changed: 2 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ public struct AuthPickerView<Content: View> {
2626
@Environment(AuthService.self) private var authService
2727
private let content: () -> Content
2828

29-
// View-layer state for handling auto-linking flow
30-
@State private var pendingCredentialForLinking: AuthCredential?
3129
// View-layer error state
3230
@State private var error: AlertError?
3331
}
@@ -76,17 +74,8 @@ extension AuthPickerView: View {
7674
okButtonLabel: authService.string.okButtonLabel
7775
)
7876
.interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled)
79-
}
80-
// View-layer logic: Handle account conflicts (auto-handle anonymous upgrade, store others for
81-
// linking)
82-
.onChange(of: authService.currentAccountConflict) { _, conflict in
83-
handleAccountConflict(conflict)
84-
}
85-
// View-layer logic: Auto-link pending credential after successful sign-in
86-
.onChange(of: authService.authenticationState) { _, newState in
87-
if newState == .authenticated {
88-
attemptAutoLinkPendingCredential()
89-
}
77+
// Apply account conflict handling at NavigationStack level
78+
.accountConflictHandler()
9079
}
9180
}
9281

@@ -100,54 +89,6 @@ extension AuthPickerView: View {
10089
}
10190
}
10291

103-
/// View-layer logic: Handle account conflicts with type-specific behavior
104-
private func handleAccountConflict(_ conflict: AccountConflictContext?) {
105-
guard let conflict = conflict else { return }
106-
107-
// Only auto-handle anonymous upgrade conflicts
108-
if conflict.conflictType == .anonymousUpgradeConflict {
109-
Task {
110-
do {
111-
// Sign out the anonymous user
112-
try await authService.signOut()
113-
114-
// Sign in with the new credential
115-
_ = try await authService.signIn(credentials: conflict.credential)
116-
117-
// Successfully handled - conflict is cleared automatically by reset()
118-
} catch let caughtError {
119-
// Show error in alert
120-
reportError(caughtError)
121-
}
122-
}
123-
} else {
124-
// Other conflicts: store credential for potential linking after sign-in
125-
pendingCredentialForLinking = conflict.credential
126-
// Show error modal for user to see and handle
127-
error = AlertError(
128-
message: conflict.message,
129-
underlyingError: conflict.underlyingError
130-
)
131-
}
132-
}
133-
134-
/// View-layer logic: Attempt to link pending credential after successful sign-in
135-
private func attemptAutoLinkPendingCredential() {
136-
guard let credential = pendingCredentialForLinking else { return }
137-
138-
Task {
139-
do {
140-
try await authService.linkAccounts(credentials: credential)
141-
// Successfully linked, clear the pending credential
142-
pendingCredentialForLinking = nil
143-
} catch let caughtError {
144-
// Show error - user is already signed in but linking failed
145-
reportError(caughtError)
146-
pendingCredentialForLinking = nil
147-
}
148-
}
149-
}
150-
15192
@ToolbarContentBuilder
15293
var toolbar: some ToolbarContent {
15394
ToolbarItem(placement: .topBarTrailing) {

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ private enum FocusableField: Hashable {
3232
@MainActor
3333
public struct EmailAuthView {
3434
@Environment(AuthService.self) private var authService
35+
@Environment(\.accountConflictHandler) private var accountConflictHandler
3536
@Environment(\.reportError) private var reportError
3637

3738
@State private var email = ""
@@ -54,23 +55,31 @@ public struct EmailAuthView {
5455
do {
5556
_ = try await authService.signIn(email: email, password: password)
5657
} catch {
57-
if let errorHandler = reportError {
58-
errorHandler(error)
59-
} else {
60-
throw error
58+
reportError?(error)
59+
60+
if case let AuthServiceError.accountConflict(ctx) = error,
61+
let onConflict = accountConflictHandler {
62+
onConflict(ctx)
63+
return
6164
}
65+
66+
throw error
6267
}
6368
}
6469

6570
private func createUserWithEmailPassword() async throws {
6671
do {
6772
_ = try await authService.createUser(email: email, password: password)
6873
} catch {
69-
if let errorHandler = reportError {
70-
errorHandler(error)
71-
} else {
72-
throw error
74+
reportError?(error)
75+
76+
if case let AuthServiceError.accountConflict(ctx) = error,
77+
let onConflict = accountConflictHandler {
78+
onConflict(ctx)
79+
return
7380
}
81+
82+
throw error
7483
}
7584
}
7685
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import SwiftUI
1919

2020
public struct EmailLinkView {
2121
@Environment(AuthService.self) private var authService
22+
@Environment(\.accountConflictHandler) private var accountConflictHandler
2223
@Environment(\.reportError) private var reportError
2324
@State private var email = ""
2425
@State private var showModal = false
@@ -94,11 +95,15 @@ extension EmailLinkView: View {
9495
do {
9596
try await authService.handleSignInLink(url: url)
9697
} catch {
97-
if let errorHandler = reportError {
98-
errorHandler(error)
99-
} else {
100-
throw error
98+
reportError?(error)
99+
100+
if case let AuthServiceError.accountConflict(ctx) = error,
101+
let onConflict = accountConflictHandler {
102+
onConflict(ctx)
103+
return
101104
}
105+
106+
throw error
102107
}
103108
}
104109
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import SwiftUI
2020
@MainActor
2121
struct EnterVerificationCodeView: View {
2222
@Environment(AuthService.self) private var authService
23+
@Environment(\.accountConflictHandler) private var accountConflictHandler
2324
@Environment(\.reportError) private var reportError
2425
@State private var verificationCode: String = ""
2526

@@ -59,11 +60,15 @@ struct EnterVerificationCodeView: View {
5960
)
6061
authService.navigator.clear()
6162
} catch {
62-
if let errorHandler = reportError {
63-
errorHandler(error)
64-
} else {
65-
throw error
63+
reportError?(error)
64+
65+
if case let AuthServiceError.accountConflict(ctx) = error,
66+
let onConflict = accountConflictHandler {
67+
onConflict(ctx)
68+
return
6669
}
70+
71+
throw error
6772
}
6873
}
6974
}) {

FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import SwiftUI
2222
@MainActor
2323
public struct SignInWithFacebookButton {
2424
@Environment(AuthService.self) private var authService
25+
@Environment(\.accountConflictHandler) private var accountConflictHandler
2526
@Environment(\.reportError) private var reportError
2627
let facebookProvider: FacebookProviderSwift
2728

@@ -41,11 +42,15 @@ extension SignInWithFacebookButton: View {
4142
do {
4243
_ = try await authService.signIn(facebookProvider)
4344
} catch {
44-
if let errorHandler = reportError {
45-
errorHandler(error)
46-
} else {
47-
throw error
45+
reportError?(error)
46+
47+
if case let AuthServiceError.accountConflict(ctx) = error,
48+
let onConflict = accountConflictHandler {
49+
onConflict(ctx)
50+
return
4851
}
52+
53+
throw error
4954
}
5055
}
5156
}

0 commit comments

Comments
 (0)