Skip to content

Commit bda7eea

Browse files
refactor: moved account conflict logic into View layer
1 parent 29840d4 commit bda7eea

11 files changed

Lines changed: 170 additions & 76 deletions

File tree

FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift

Lines changed: 8 additions & 1 deletion
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
let provider: AuthProviderSwift
2425
public init(provider: AuthProviderSwift) {
2526
self.provider = provider
@@ -34,7 +35,13 @@ extension SignInWithAppleButton: View {
3435
accessibilityId: "sign-in-with-apple-button"
3536
) {
3637
Task {
37-
try? await authService.signIn(provider)
38+
do {
39+
_ = try await authService.signIn(provider)
40+
} catch let AuthServiceError.accountConflict(context) {
41+
accountConflictHandler(context)
42+
} catch {
43+
// Other errors handled by .errorAlert()
44+
}
3845
}
3946
}
4047
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,6 @@ public final class AuthService {
153153
public var currentMFARequired: MFARequired?
154154
private var currentMFAResolver: MultiFactorResolver?
155155

156-
/// Current account conflict context - observe this to handle conflicts and update backend
157-
public private(set) var currentAccountConflict: AccountConflictContext?
158-
159156
// MARK: - Provider APIs
160157

161158
private var listenerManager: AuthListenerManager?
@@ -225,7 +222,6 @@ public final class AuthService {
225222

226223
func reset() {
227224
_currentError = nil
228-
currentAccountConflict = nil
229225
}
230226

231227
func updateError(title: String = "Error", message: String, underlyingError: Error? = nil) {
@@ -908,7 +904,7 @@ public extension AuthService {
908904
)
909905
}
910906

911-
/// Handles account conflict errors by creating context, storing it, and throwing structured error
907+
/// Handles account conflict errors by creating context and throwing structured error
912908
/// - Parameters:
913909
/// - error: The error to check and handle
914910
/// - credential: The credential that caused the conflict
@@ -925,15 +921,12 @@ public extension AuthService {
925921
credential: credential
926922
)
927923

928-
// Store it for consumers to observe
929-
currentAccountConflict = context
930-
931924
// Only set error alert if we're NOT auto-handling it
932925
if conflictType != .anonymousUpgradeConflict {
933926
updateError(message: context.message, underlyingError: error)
934927
}
935928

936-
// Throw the specific error with context
929+
// Throw the specific error with context - view layer handles it
937930
throw AuthServiceError.accountConflict(context)
938931
} else {
939932
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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 = { _ in }
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+
@State private var pendingCredentialForLinking: AuthCredential?
36+
37+
func body(content: Content) -> some View {
38+
content
39+
.environment(\.accountConflictHandler, handleAccountConflict)
40+
.onChange(of: authService.authenticationState) { _, newState in
41+
// Auto-link pending credential after successful sign-in
42+
if newState == .authenticated {
43+
attemptAutoLinkPendingCredential()
44+
}
45+
}
46+
}
47+
48+
/// Handle account conflicts - auto-resolve anonymous upgrades, store others for linking
49+
func handleAccountConflict(_ conflict: AccountConflictContext) {
50+
// Only auto-handle anonymous upgrade conflicts
51+
if conflict.conflictType == .anonymousUpgradeConflict {
52+
Task {
53+
do {
54+
// Sign out the anonymous user
55+
try await authService.signOut()
56+
57+
// Sign in with the new credential
58+
_ = try await authService.signIn(credentials: conflict.credential)
59+
60+
// Successfully handled - conflict is cleared automatically by reset()
61+
} catch {
62+
// Error will be shown via normal error handling
63+
// Credential is still stored if they want to retry
64+
}
65+
}
66+
} else {
67+
// Other conflicts: store credential for potential linking after sign-in
68+
pendingCredentialForLinking = conflict.credential
69+
// Error modal will show for user to see and handle
70+
}
71+
}
72+
73+
/// Attempt to link pending credential after successful sign-in
74+
private func attemptAutoLinkPendingCredential() {
75+
guard let credential = pendingCredentialForLinking else { return }
76+
77+
Task {
78+
do {
79+
try await authService.linkAccounts(credentials: credential)
80+
// Successfully linked, clear the pending credential
81+
pendingCredentialForLinking = nil
82+
} catch {
83+
// Silently swallow linking errors - user is already signed in
84+
// Consumer's custom views can observe authService.currentError if they want to handle this
85+
pendingCredentialForLinking = nil
86+
}
87+
}
88+
}
89+
}
90+
91+
extension View {
92+
/// Adds account conflict handling to the view hierarchy
93+
/// Should be applied at the NavigationStack level to handle conflicts throughout the auth flow
94+
func accountConflictHandler() -> some View {
95+
modifier(AccountConflictModifier())
96+
}
97+
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift

Lines changed: 2 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,6 @@ public struct AuthPickerView<Content: View> {
2525

2626
@Environment(AuthService.self) private var authService
2727
private let content: () -> Content
28-
29-
// View-layer state for handling auto-linking flow
30-
@State private var pendingCredentialForLinking: AuthCredential?
3128
}
3229

3330
extension AuthPickerView: View {
@@ -69,62 +66,9 @@ extension AuthPickerView: View {
6966
}
7067
}
7168
.interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled)
69+
// Apply account conflict handling at NavigationStack level
70+
.accountConflictHandler()
7271
}
73-
// View-layer logic: Handle account conflicts (auto-handle anonymous upgrade, store others for
74-
// linking)
75-
.onChange(of: authService.currentAccountConflict) { _, conflict in
76-
handleAccountConflict(conflict)
77-
}
78-
// View-layer logic: Auto-link pending credential after successful sign-in
79-
.onChange(of: authService.authenticationState) { _, newState in
80-
if newState == .authenticated {
81-
attemptAutoLinkPendingCredential()
82-
}
83-
}
84-
}
85-
86-
/// View-layer logic: Handle account conflicts with type-specific behavior
87-
private func handleAccountConflict(_ conflict: AccountConflictContext?) {
88-
guard let conflict = conflict else { return }
89-
90-
// Only auto-handle anonymous upgrade conflicts
91-
if conflict.conflictType == .anonymousUpgradeConflict {
92-
Task {
93-
do {
94-
// Sign out the anonymous user
95-
try await authService.signOut()
96-
97-
// Sign in with the new credential
98-
_ = try await authService.signIn(credentials: conflict.credential)
99-
100-
// Successfully handled - conflict and error are cleared automatically by reset()
101-
} catch {
102-
// Error will be shown via normal error handling
103-
// Credential is still stored if they want to retry
104-
}
105-
}
106-
} else {
107-
// Other conflicts: store credential for potential linking after sign-in
108-
pendingCredentialForLinking = conflict.credential
109-
// Error modal will show for user to see and handle
110-
}
111-
}
112-
113-
/// View-layer logic: Attempt to link pending credential after successful sign-in
114-
private func attemptAutoLinkPendingCredential() {
115-
guard let credential = pendingCredentialForLinking else { return }
116-
117-
Task {
118-
do {
119-
try await authService.linkAccounts(credentials: credential)
120-
// Successfully linked, clear the pending credential
121-
pendingCredentialForLinking = nil
122-
} catch {
123-
// Silently swallow linking errors - user is already signed in
124-
// Consumer's custom views can observe authService.currentError if they want to handle this
125-
pendingCredentialForLinking = nil
126-
}
127-
}
12872
}
12973

13074
@ToolbarContentBuilder

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift

Lines changed: 15 additions & 2 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

3637
@State private var email = ""
3738
@State private var password = ""
@@ -50,11 +51,23 @@ public struct EmailAuthView {
5051
}
5152

5253
private func signInWithEmailPassword() async {
53-
try? await authService.signIn(email: email, password: password)
54+
do {
55+
_ = try await authService.signIn(email: email, password: password)
56+
} catch let AuthServiceError.accountConflict(context) {
57+
accountConflictHandler(context)
58+
} catch {
59+
// Other errors handled by .errorAlert()
60+
}
5461
}
5562

5663
private func createUserWithEmailPassword() async {
57-
try? await authService.createUser(email: email, password: password)
64+
do {
65+
_ = try await authService.createUser(email: email, password: password)
66+
} catch let AuthServiceError.accountConflict(context) {
67+
accountConflictHandler(context)
68+
} catch {
69+
// Other errors handled by .errorAlert()
70+
}
5871
}
5972
}
6073

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift

Lines changed: 8 additions & 1 deletion
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
@State private var email = ""
2324
@State private var showModal = false
2425

@@ -86,7 +87,13 @@ extension EmailLinkView: View {
8687
}
8788
.onOpenURL { url in
8889
Task {
89-
try? await authService.handleSignInLink(url: url)
90+
do {
91+
try await authService.handleSignInLink(url: url)
92+
} catch let AuthServiceError.accountConflict(context) {
93+
accountConflictHandler(context)
94+
} catch {
95+
// Other errors handled by .errorAlert()
96+
}
9097
}
9198
}
9299
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift

Lines changed: 6 additions & 1 deletion
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
@State private var verificationCode: String = ""
2425

2526
let verificationID: String
@@ -62,7 +63,11 @@ struct EnterVerificationCodeView: View {
6263

6364
_ = try await authService.signIn(credentials: credential)
6465
authService.navigator.clear()
65-
} catch {}
66+
} catch let AuthServiceError.accountConflict(context) {
67+
accountConflictHandler(context)
68+
} catch {
69+
// Other errors handled by .errorAlert()
70+
}
6671
}
6772
}) {
6873
if authService.authenticationState == .authenticating {

FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift

Lines changed: 8 additions & 1 deletion
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
let facebookProvider: FacebookProviderSwift
2627

2728
public init(facebookProvider: FacebookProviderSwift) {
@@ -37,7 +38,13 @@ extension SignInWithFacebookButton: View {
3738
accessibilityId: "sign-in-with-facebook-button"
3839
) {
3940
Task {
40-
try? await authService.signIn(facebookProvider)
41+
do {
42+
_ = try await authService.signIn(facebookProvider)
43+
} catch let AuthServiceError.accountConflict(context) {
44+
accountConflictHandler(context)
45+
} catch {
46+
// Other errors handled by .errorAlert()
47+
}
4148
}
4249
}
4350
}

FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import SwiftUI
2626
@MainActor
2727
public struct SignInWithGoogleButton {
2828
@Environment(AuthService.self) private var authService
29+
@Environment(\.accountConflictHandler) private var accountConflictHandler
2930
let googleProvider: AuthProviderSwift
3031

3132
public init(googleProvider: AuthProviderSwift) {
@@ -41,7 +42,13 @@ extension SignInWithGoogleButton: View {
4142
accessibilityId: "sign-in-with-google-button"
4243
) {
4344
Task {
44-
try? await authService.signIn(googleProvider)
45+
do {
46+
_ = try await authService.signIn(googleProvider)
47+
} catch let AuthServiceError.accountConflict(context) {
48+
accountConflictHandler(context)
49+
} catch {
50+
// Other errors handled by .errorAlert()
51+
}
4552
}
4653
}
4754
}

FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import SwiftUI
2020
@MainActor
2121
public struct GenericOAuthButton {
2222
@Environment(AuthService.self) private var authService
23+
@Environment(\.accountConflictHandler) private var accountConflictHandler
2324
let provider: AuthProviderSwift
2425
public init(provider: AuthProviderSwift) {
2526
self.provider = provider
@@ -51,7 +52,13 @@ extension GenericOAuthButton: View {
5152
accessibilityId: "sign-in-with-\(oauthProvider.providerId)-button"
5253
) {
5354
Task {
54-
try? await authService.signIn(provider)
55+
do {
56+
_ = try await authService.signIn(provider)
57+
} catch let AuthServiceError.accountConflict(context) {
58+
accountConflictHandler(context)
59+
} catch {
60+
// Other errors handled by .errorAlert()
61+
}
5562
}
5663
}
5764
)

0 commit comments

Comments
 (0)