Skip to content

Commit d18752f

Browse files
fix: anonymous upgrade, link accounts silently if possible
1 parent 3263f38 commit d18752f

File tree

4 files changed

+139
-27
lines changed

4 files changed

+139
-27
lines changed

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,59 @@
1515
import FirebaseAuth
1616
import SwiftUI
1717

18-
public struct AccountMergeConflictContext: LocalizedError, Identifiable {
18+
/// Describes the specific type of account conflict that occurred
19+
public enum AccountConflictType: Equatable {
20+
/// Account exists with a different provider (e.g., user signed up with Google, trying to use email)
21+
/// Solution: Sign in with existing provider, then link the new credential
22+
case accountExistsWithDifferentCredential
23+
24+
/// The credential is already linked to another account
25+
/// Solution: User must sign in with that account or unlink the credential
26+
case credentialAlreadyInUse
27+
28+
/// Email is already registered with another method
29+
/// Solution: Sign in with existing method, then link if desired
30+
case emailAlreadyInUse
31+
32+
/// Trying to link anonymous account to an existing account
33+
/// Solution: Sign out of anonymous, then sign in with the credential
34+
case anonymousUpgradeConflict
35+
}
36+
37+
public struct AccountConflictContext: LocalizedError, Identifiable, Equatable {
1938
public let id = UUID()
39+
public let conflictType: AccountConflictType
2040
public let credential: AuthCredential
2141
public let underlyingError: Error
2242
public let message: String
23-
public let uid: String?
2443
public let email: String?
25-
public let requiresManualLinking: Bool
26-
44+
public let existingProviderIds: [String]?
45+
46+
/// Indicates if this conflict occurred during anonymous user upgrade
47+
public let isAnonymousUpgrade: Bool
48+
49+
/// Human-readable description of the conflict type
50+
public var conflictDescription: String {
51+
switch conflictType {
52+
case .accountExistsWithDifferentCredential:
53+
return "This account is already registered with a different sign-in method."
54+
case .credentialAlreadyInUse:
55+
return "This credential is already linked to another account."
56+
case .emailAlreadyInUse:
57+
return "This email address is already in use."
58+
case .anonymousUpgradeConflict:
59+
return "Cannot link anonymous account to an existing account."
60+
}
61+
}
62+
2763
public var errorDescription: String? {
2864
return message
2965
}
66+
67+
public static func == (lhs: AccountConflictContext, rhs: AccountConflictContext) -> Bool {
68+
// Compare by id since each AccountConflictContext instance is unique
69+
lhs.id == rhs.id
70+
}
3071
}
3172

3273
public enum AuthServiceError: LocalizedError {
@@ -37,7 +78,7 @@ public enum AuthServiceError: LocalizedError {
3778
case reauthenticationRequired(String)
3879
case invalidCredentials(String)
3980
case signInFailed(underlying: Error)
40-
case accountMergeConflict(context: AccountMergeConflictContext)
81+
case accountConflict(AccountConflictContext)
4182
case providerNotFound(String)
4283
case multiFactorAuth(String)
4384
case rootViewControllerNotFound(String)
@@ -66,7 +107,7 @@ public enum AuthServiceError: LocalizedError {
66107
return description
67108
case let .signInCancelled(description):
68109
return description
69-
case let .accountMergeConflict(context):
110+
case let .accountConflict(context):
70111
return context.errorDescription
71112
case let .providerNotFound(description):
72113
return description

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,9 @@ public final class AuthService {
134134
public let passwordPrompt: PasswordPromptCoordinator = .init()
135135
public var currentMFARequired: MFARequired?
136136
private var currentMFAResolver: MultiFactorResolver?
137+
138+
/// Current account conflict context - observe this to handle conflicts and update backend
139+
public private(set) var currentAccountConflict: AccountConflictContext?
137140

138141
// MARK: - Provider APIs
139142

@@ -205,6 +208,7 @@ public final class AuthService {
205208

206209
func reset() {
207210
currentError = nil
211+
currentAccountConflict = nil
208212
}
209213

210214
func updateError(title: String = "Error", message: String, underlyingError: Error? = nil) {
@@ -279,6 +283,25 @@ public final class AuthService {
279283
.userInfo[AuthErrorUserInfoMultiFactorResolverKey] as? MultiFactorResolver {
280284
return handleMFARequiredError(resolver: resolver)
281285
}
286+
}
287+
// Check for account conflict errors
288+
else if let conflictType = determineConflictType(from: error) {
289+
let context = createConflictContext(
290+
from: error,
291+
conflictType: conflictType,
292+
credential: credentials
293+
)
294+
295+
// Store it for consumers to observe
296+
currentAccountConflict = context
297+
298+
// Only set error alert if we're NOT auto-handling it
299+
if conflictType != .anonymousUpgradeConflict {
300+
updateError(message: context.message, underlyingError: error)
301+
}
302+
303+
// Throw the specific error with context
304+
throw AuthServiceError.accountConflict(context)
282305
} else {
283306
// Don't want error modal on MFA error so we only update here
284307
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
@@ -370,9 +393,9 @@ public extension AuthService {
370393
}
371394
} catch {
372395
authenticationState = .unauthenticated
373-
// ALWAYS store error - let view layer decide what to do
396+
// store error if consumer wants to handle it
374397
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
375-
// ALWAYS throw - let view layer decide how to handle
398+
// throw error to view layer
376399
throw error
377400
}
378401
}
@@ -832,6 +855,40 @@ public extension AuthService {
832855
}
833856
}
834857

858+
// MARK: - Account Conflict Helper Methods
859+
860+
private func determineConflictType(from error: NSError) -> AccountConflictType? {
861+
switch error.code {
862+
case AuthErrorCode.accountExistsWithDifferentCredential.rawValue:
863+
return shouldHandleAnonymousUpgrade ? .anonymousUpgradeConflict : .accountExistsWithDifferentCredential
864+
case AuthErrorCode.credentialAlreadyInUse.rawValue:
865+
return shouldHandleAnonymousUpgrade ? .anonymousUpgradeConflict : .credentialAlreadyInUse
866+
case AuthErrorCode.emailAlreadyInUse.rawValue:
867+
return shouldHandleAnonymousUpgrade ? .anonymousUpgradeConflict :.emailAlreadyInUse
868+
default:
869+
return nil
870+
}
871+
}
872+
873+
private func createConflictContext(
874+
from error: NSError,
875+
conflictType: AccountConflictType,
876+
credential: AuthCredential
877+
) -> AccountConflictContext {
878+
let updatedCredential = error.userInfo[AuthErrorUserInfoUpdatedCredentialKey] as? AuthCredential ?? credential
879+
let email = error.userInfo[AuthErrorUserInfoEmailKey] as? String
880+
881+
return AccountConflictContext(
882+
conflictType: conflictType,
883+
credential: updatedCredential,
884+
underlyingError: error,
885+
message: string.localizedErrorMessage(for: error),
886+
email: email,
887+
existingProviderIds: nil,
888+
isAnonymousUpgrade: shouldHandleAnonymousUpgrade
889+
)
890+
}
891+
835892
// MARK: - MFA Helper Methods
836893

837894
private func extractMFAHints(from resolver: MultiFactorResolver) -> [MFAHint] {

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ extension AuthPickerView: View {
6363
}
6464
.interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled)
6565
}
66-
// View-layer logic: Intercept credential conflict errors and store for auto-linking
67-
.onChange(of: authService.currentError) { _, newValue in
68-
handleCredentialConflictError(newValue)
66+
// View-layer logic: Handle account conflicts (auto-handle anonymous upgrade, store others for linking)
67+
.onChange(of: authService.currentAccountConflict) { _, conflict in
68+
handleAccountConflict(conflict)
6969
}
7070
// View-layer logic: Auto-link pending credential after successful sign-in
7171
.onChange(of: authService.authenticationState) { _, newState in
@@ -75,23 +75,30 @@ extension AuthPickerView: View {
7575
}
7676
}
7777

78-
/// View-layer logic: Handle credential conflict errors by storing credential for auto-linking
79-
private func handleCredentialConflictError(_ error: AlertError?) {
80-
guard let error = error,
81-
let nsError = error.underlyingError as? NSError else { return }
78+
/// View-layer logic: Handle account conflicts with type-specific behavior
79+
private func handleAccountConflict(_ conflict: AccountConflictContext?) {
80+
guard let conflict = conflict else { return }
8281

83-
// Check if this is a credential conflict error that should trigger auto-linking
84-
let shouldStoreCredential =
85-
nsError.code == AuthErrorCode.accountExistsWithDifferentCredential.rawValue || // 17007
86-
nsError.code == AuthErrorCode.credentialAlreadyInUse.rawValue || // 17025
87-
nsError.code == AuthErrorCode.emailAlreadyInUse.rawValue || // 17020
88-
nsError.code == 17094 // duplicate credential
89-
90-
if shouldStoreCredential {
91-
// Extract the credential from the error and store it
92-
let credential = nsError.userInfo[AuthErrorUserInfoUpdatedCredentialKey] as? AuthCredential
93-
pendingCredentialForLinking = credential
94-
// Error still propagates to user via normal error modal
82+
// Only auto-handle anonymous upgrade conflicts
83+
if conflict.conflictType == .anonymousUpgradeConflict {
84+
Task {
85+
do {
86+
// Sign out the anonymous user
87+
try await authService.signOut()
88+
89+
// Sign in with the new credential
90+
_ = try await authService.signIn(credentials: conflict.credential)
91+
92+
// Successfully handled - conflict and error are cleared automatically by reset()
93+
} catch {
94+
// Error will be shown via normal error handling
95+
// Credential is still stored if they want to retry
96+
}
97+
}
98+
} else {
99+
// Other conflicts: store credential for potential linking after sign-in
100+
pendingCredentialForLinking = conflict.credential
101+
// Error modal will show for user to see and handle
95102
}
96103
}
97104

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ struct ErrorAlertModifier: ViewModifier {
2828
if error.underlyingError is CancellationError {
2929
return false
3030
}
31+
32+
// Don't show alert for anonymous upgrade conflicts (they're auto-handled)
33+
if let authError = error.underlyingError as? AuthServiceError,
34+
case .accountConflict(let context) = authError,
35+
context.conflictType == .anonymousUpgradeConflict {
36+
return false
37+
}
3138

3239
return true
3340
}

0 commit comments

Comments
 (0)