Skip to content

Commit a0a5e77

Browse files
feat: attempt re auth in Views if credential already exists
1 parent 836154b commit a0a5e77

5 files changed

Lines changed: 222 additions & 13 deletions

File tree

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515
import FirebaseAuth
1616
import SwiftUI
1717

18-
public struct AccountMergeConflictContext: LocalizedError {
18+
public struct AccountMergeConflictContext: LocalizedError, Identifiable {
19+
public let id = UUID()
1920
public let credential: AuthCredential
2021
public let underlyingError: Error
2122
public let message: String
22-
// TODO: - should make this User type once fixed upstream in firebase-ios-sdk. See: https://github.com/firebase/FirebaseUI-iOS/pull/1247#discussion_r2085455355
2323
public let uid: String?
24+
public let email: String?
25+
public let requiresManualLinking: Bool
2426

2527
public var errorDescription: String? {
2628
return message

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,24 @@ public final class AuthService {
245245
}
246246
}
247247

248+
// Real account? → Requires proof of ownership → requiresManualLinking = true
249+
private func handleAccountExistsError(_ error: NSError, with credentials: AuthCredential) throws {
250+
let email = error.userInfo["FIRAuthErrorUserInfoEmailKey"] as? String
251+
let updatedCredential = error.userInfo["FIRAuthUpdatedCredentialKey"] as? AuthCredential
252+
?? credentials
253+
254+
let context = AccountMergeConflictContext(
255+
credential: updatedCredential,
256+
underlyingError: error,
257+
message: "An account already exists with \(email ?? "this email"). Please sign in with your existing method to link accounts.",
258+
uid: nil,
259+
email: email,
260+
requiresManualLinking: true
261+
)
262+
263+
throw AuthServiceError.accountMergeConflict(context: context)
264+
}
265+
248266
private func handleAutoUpgradeAnonymousUser(credentials: AuthCredential) async throws
249267
-> SignInOutcome {
250268
if currentUser == nil {
@@ -255,17 +273,24 @@ public final class AuthService {
255273
updateAuthenticationState()
256274
return .signedIn(result)
257275
} catch let error as NSError {
276+
// Handle accountExistsWithDifferentCredential error
277+
if error.code == AuthErrorCode.accountExistsWithDifferentCredential.rawValue {
278+
try handleAccountExistsError(error, with: credentials)
279+
}
280+
258281
// Handle credentialAlreadyInUse error
259282
if error.code == AuthErrorCode.credentialAlreadyInUse.rawValue {
260283
// Extract the updated credential from the error
261284
let updatedCredential = error.userInfo["FIRAuthUpdatedCredentialKey"] as? AuthCredential
262285
?? credentials
263-
264286
let context = AccountMergeConflictContext(
265287
credential: updatedCredential,
266288
underlyingError: error,
267289
message: "Unable to merge accounts. The credential is already associated with a different account.",
268-
uid: currentUser?.uid
290+
uid: currentUser?.uid,
291+
email: nil,
292+
// Anonymous account? → Safe to discard → requiresManualLinking = false
293+
requiresManualLinking: false
269294
)
270295
throw AuthServiceError.accountMergeConflict(context: context)
271296
}
@@ -276,7 +301,10 @@ public final class AuthService {
276301
credential: credentials,
277302
underlyingError: error,
278303
message: "Unable to merge accounts. This email is already associated with a different account.",
279-
uid: currentUser?.uid
304+
uid: currentUser?.uid,
305+
email: nil,
306+
// Anonymous account? → Safe to discard → requiresManualLinking = false
307+
requiresManualLinking: false
280308
)
281309
throw AuthServiceError.accountMergeConflict(context: context)
282310
}
@@ -296,6 +324,12 @@ public final class AuthService {
296324
}
297325
} catch let error as NSError {
298326
authenticationState = .unauthenticated
327+
328+
// Handle account exists with different credential
329+
if error.code == AuthErrorCode.accountExistsWithDifferentCredential.rawValue {
330+
try handleAccountExistsError(error, with: credentials)
331+
}
332+
299333
// Check if this is an MFA required error
300334
if error.code == AuthErrorCode.secondFactorRequired.rawValue {
301335
if let resolver = error
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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 FirebaseAuthUIComponents
17+
import FirebaseCore
18+
import SwiftUI
19+
20+
@MainActor
21+
public struct AccountLinkingView {
22+
@Environment(AuthService.self) private var authService
23+
@Environment(\.dismiss) private var dismiss
24+
25+
let context: AccountMergeConflictContext
26+
27+
public init(context: AccountMergeConflictContext) {
28+
self.context = context
29+
}
30+
}
31+
32+
extension AccountLinkingView: View {
33+
public var body: some View {
34+
VStack(spacing: 24) {
35+
// Warning icon
36+
Image(systemName: "exclamationmark.triangle.fill")
37+
.resizable()
38+
.aspectRatio(contentMode: .fit)
39+
.frame(width: 60, height: 60)
40+
.foregroundColor(.orange)
41+
42+
// Title
43+
Text("Account Already Exists")
44+
.font(.title2)
45+
.fontWeight(.bold)
46+
47+
// Message
48+
Text(
49+
"An account with **\(context.email ?? "this email")** already exists. Please sign in with your existing authentication method below to link your accounts."
50+
)
51+
.multilineTextAlignment(.center)
52+
.fixedSize(horizontal: false, vertical: true)
53+
54+
Divider()
55+
56+
// Sign in methods section
57+
VStack(spacing: 16) {
58+
Text("Sign in with your existing method:")
59+
.font(.headline)
60+
61+
if authService.emailSignInEnabled {
62+
EmailAuthView()
63+
.environment(\.signInWithMergeConflictHandler, signInForAccountLinking)
64+
}
65+
66+
// Show other provider buttons
67+
authService.renderButtons()
68+
.environment(\.signInWithMergeConflictHandler, signInForAccountLinking)
69+
}
70+
71+
PrivacyTOCsView(displayMode: .full)
72+
}
73+
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
74+
.safeAreaPadding()
75+
.navigationTitle("Link Accounts")
76+
.navigationBarTitleDisplayMode(.inline)
77+
}
78+
79+
/// Custom sign-in handler for account linking flow
80+
private func signInForAccountLinking(authService: AuthService,
81+
signInAction: () async throws -> SignInOutcome) async throws
82+
-> SignInOutcome {
83+
do {
84+
// Attempt to sign in with the existing provider
85+
let outcome = try await signInAction()
86+
87+
// If successful, link the pending credential
88+
if case .signedIn = outcome {
89+
try await authService.linkAccounts(credentials: context.credential)
90+
// Dismiss the sheet after successful linking
91+
dismiss()
92+
}
93+
94+
return outcome
95+
} catch {
96+
// Re-throw the error for normal error handling
97+
throw error
98+
}
99+
}
100+
}
101+
102+
#Preview {
103+
FirebaseOptions.dummyConfigurationForPreview()
104+
let authService = AuthService().withEmailSignIn()
105+
106+
let context = AccountMergeConflictContext(
107+
credential: EmailAuthProvider.credential(
108+
withEmail: "user@example.com",
109+
password: "password"
110+
),
111+
underlyingError: NSError(domain: "Test", code: 0),
112+
message: "Test error",
113+
uid: nil,
114+
email: "user@example.com",
115+
requiresManualLinking: true
116+
)
117+
118+
return NavigationStack {
119+
AccountLinkingView(context: context)
120+
.environment(authService)
121+
}
122+
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,15 @@ import SwiftUI
2525
/// an existing account), it automatically signs out the anonymous user and signs in with
2626
/// the existing account's credential.
2727
///
28+
/// For conflicts that require manual linking (accountExistsWithDifferentCredential),
29+
/// the error is re-thrown to be handled by the view layer with AccountLinkingView.
30+
///
2831
/// - Parameters:
2932
/// - authService: The AuthService instance to use for sign-in operations
3033
/// - signInAction: An async closure that performs the sign-in operation
3134
/// - Returns: The SignInOutcome from the successful sign-in
32-
/// - Throws: Re-throws any errors except accountMergeConflict (which is handled internally)
35+
/// - Throws: Re-throws any errors except automatic accountMergeConflict (which is handled
36+
/// internally)
3337
@MainActor
3438
public func signInWithMergeConflictHandling(authService: AuthService,
3539
signInAction: () async throws
@@ -38,13 +42,19 @@ public func signInWithMergeConflictHandling(authService: AuthService,
3842
return try await signInAction()
3943
} catch let error as AuthServiceError {
4044
if case let .accountMergeConflict(context) = error {
41-
// The anonymous account conflicts with an existing account
42-
// Sign out the anonymous user
43-
try await authService.signOut()
45+
// Check if this requires manual linking (UI flow) or automatic
46+
if context.requiresManualLinking {
47+
// Re-throw for view layer to handle with AccountLinkingView
48+
throw error
49+
} else {
50+
// Automatic handling for anonymous upgrade
51+
// Sign out the anonymous user
52+
try await authService.signOut()
4453

45-
// Sign in with the existing account's credential
46-
// This works because shouldHandleAnonymousUpgrade is now false after sign out
47-
return try await authService.signIn(credentials: context.credential)
54+
// Sign in with the existing account's credential
55+
// This works because shouldHandleAnonymousUpgrade is now false after sign out
56+
return try await authService.signIn(credentials: context.credential)
57+
}
4858
}
4959
throw error
5060
}
@@ -77,6 +87,9 @@ public struct AuthPickerView<Content: View> {
7787

7888
@Environment(AuthService.self) private var authService
7989
private let content: () -> Content
90+
91+
// State for account linking
92+
@State private var accountLinkingContext: AccountMergeConflictContext?
8093
}
8194

8295
extension AuthPickerView: View {
@@ -111,6 +124,39 @@ extension AuthPickerView: View {
111124
}
112125
}
113126
.interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled)
127+
// Add sheet for account linking
128+
.sheet(item: Binding(
129+
get: { accountLinkingContext },
130+
set: { accountLinkingContext = $0 }
131+
)) { context in
132+
NavigationStack {
133+
AccountLinkingView(context: context)
134+
.environment(authService)
135+
.toolbar {
136+
ToolbarItem(placement: .topBarTrailing) {
137+
if !authService.configuration.shouldHideCancelButton {
138+
Button {
139+
accountLinkingContext = nil
140+
} label: {
141+
Image(systemName: "xmark")
142+
}
143+
}
144+
}
145+
}
146+
}
147+
}
148+
}
149+
// Intercept account linking errors
150+
.onChange(of: authService.currentError) { _, newValue in
151+
// Check if the underlying error is accountMergeConflict with manual linking required
152+
if let error = newValue?.underlyingError as? AuthServiceError,
153+
case let .accountMergeConflict(context) = error,
154+
context.requiresManualLinking {
155+
// Clear the error (we're handling it with the sheet)
156+
authService.currentError = nil
157+
// Show the account linking sheet
158+
accountLinkingContext = context
159+
}
114160
}
115161
}
116162

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public extension View {
4848
}
4949

5050
/// A struct to represent an error that should be displayed in an alert
51-
public struct AlertError: Identifiable {
51+
public struct AlertError: Identifiable, Equatable {
5252
public let id = UUID()
5353
public let title: String
5454
public let message: String
@@ -59,4 +59,9 @@ public struct AlertError: Identifiable {
5959
self.message = message
6060
self.underlyingError = underlyingError
6161
}
62+
63+
public static func == (lhs: AlertError, rhs: AlertError) -> Bool {
64+
// Compare by id since each AlertError instance is unique
65+
lhs.id == rhs.id
66+
}
6267
}

0 commit comments

Comments
 (0)