Skip to content

Commit 836154b

Browse files
Merge pull request #1291 from firebase/anonymous-upgrade
2 parents 06a3688 + 3a71f54 commit 836154b

File tree

14 files changed

+449
-319
lines changed

14 files changed

+449
-319
lines changed

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(\.signInWithMergeConflictHandler) private var signInHandler
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+
if let handler = signInHandler {
39+
try? await handler(authService) {
40+
try await authService.signIn(provider)
41+
}
42+
} else {
43+
try? await authService.signIn(provider)
44+
}
3845
}
3946
}
4047
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift

Lines changed: 51 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ public protocol AuthProviderUI {
2727
var provider: AuthProviderSwift { get }
2828
}
2929

30-
public protocol PhoneAuthProviderSwift: AuthProviderSwift {
31-
@MainActor func verifyPhoneNumber(phoneNumber: String) async throws -> String
32-
func setVerificationCode(verificationID: String, code: String)
30+
public protocol PhoneAuthProviderSwift: AuthProviderSwift, AnyObject {
31+
// Phone auth provider that presents its own UI flow in createAuthCredential()
32+
// Internal use only: AuthService will be injected automatically by AuthService.signIn()
33+
var authService: AuthService? { get set }
3334
}
3435

3536
public enum AuthenticationState {
@@ -50,8 +51,6 @@ public enum AuthView: Hashable {
5051
case mfaEnrollment
5152
case mfaManagement
5253
case mfaResolution
53-
case enterPhoneNumber
54-
case enterVerificationCode(verificationID: String, fullPhoneNumber: String)
5554
}
5655

5756
public enum SignInOutcome: @unchecked Sendable {
@@ -144,10 +143,6 @@ public final class AuthService {
144143

145144
private var providers: [AuthProviderUI] = []
146145

147-
public var currentPhoneProvider: PhoneAuthProviderSwift? {
148-
providers.compactMap { $0.provider as? PhoneAuthProviderSwift }.first
149-
}
150-
151146
public func registerProvider(providerWithButton: AuthProviderUI) {
152147
providers.append(providerWithButton)
153148
}
@@ -171,11 +166,17 @@ public final class AuthService {
171166

172167
public func signIn(_ provider: AuthProviderSwift) async throws -> SignInOutcome {
173168
do {
169+
// Automatically inject AuthService for phone provider
170+
if let phoneProvider = provider as? PhoneAuthProviderSwift {
171+
phoneProvider.authService = self
172+
}
173+
174174
let credential = try await provider.createAuthCredential()
175175
let result = try await signIn(credentials: credential)
176176
return result
177177
} catch {
178-
updateError(message: string.localizedErrorMessage(for: error))
178+
// Always pass the underlying error - view decides what to show
179+
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
179180
throw error
180181
}
181182
}
@@ -206,8 +207,8 @@ public final class AuthService {
206207
currentError = nil
207208
}
208209

209-
func updateError(title: String = "Error", message: String) {
210-
currentError = AlertError(title: title, message: message)
210+
func updateError(title: String = "Error", message: String, underlyingError: Error? = nil) {
211+
currentError = AlertError(title: title, message: message, underlyingError: underlyingError)
211212
}
212213

213214
public var shouldHandleAnonymousUpgrade: Bool {
@@ -217,9 +218,11 @@ public final class AuthService {
217218
public func signOut() async throws {
218219
do {
219220
try await auth.signOut()
221+
// Cannot wait for auth listener to change, feedback needs to be immediate
222+
currentUser = nil
220223
updateAuthenticationState()
221224
} catch {
222-
updateError(message: string.localizedErrorMessage(for: error))
225+
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
223226
throw error
224227
}
225228
}
@@ -237,12 +240,12 @@ public final class AuthService {
237240
updateAuthenticationState()
238241
} catch {
239242
authenticationState = .unauthenticated
240-
updateError(message: string.localizedErrorMessage(for: error))
243+
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
241244
throw error
242245
}
243246
}
244247

245-
public func handleAutoUpgradeAnonymousUser(credentials: AuthCredential) async throws
248+
private func handleAutoUpgradeAnonymousUser(credentials: AuthCredential) async throws
246249
-> SignInOutcome {
247250
if currentUser == nil {
248251
throw AuthServiceError.noCurrentUser
@@ -252,11 +255,27 @@ public final class AuthService {
252255
updateAuthenticationState()
253256
return .signedIn(result)
254257
} catch let error as NSError {
258+
// Handle credentialAlreadyInUse error
259+
if error.code == AuthErrorCode.credentialAlreadyInUse.rawValue {
260+
// Extract the updated credential from the error
261+
let updatedCredential = error.userInfo["FIRAuthUpdatedCredentialKey"] as? AuthCredential
262+
?? credentials
263+
264+
let context = AccountMergeConflictContext(
265+
credential: updatedCredential,
266+
underlyingError: error,
267+
message: "Unable to merge accounts. The credential is already associated with a different account.",
268+
uid: currentUser?.uid
269+
)
270+
throw AuthServiceError.accountMergeConflict(context: context)
271+
}
272+
273+
// Handle emailAlreadyInUse error
255274
if error.code == AuthErrorCode.emailAlreadyInUse.rawValue {
256275
let context = AccountMergeConflictContext(
257276
credential: credentials,
258277
underlyingError: error,
259-
message: "Unable to merge accounts. Use the credential in the context to resolve the conflict.",
278+
message: "Unable to merge accounts. This email is already associated with a different account.",
260279
uid: currentUser?.uid
261280
)
262281
throw AuthServiceError.accountMergeConflict(context: context)
@@ -285,7 +304,7 @@ public final class AuthService {
285304
}
286305
} else {
287306
// Don't want error modal on MFA error so we only update here
288-
updateError(message: string.localizedErrorMessage(for: error))
307+
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
289308
}
290309

291310
throw error
@@ -307,7 +326,7 @@ public final class AuthService {
307326
}
308327
}
309328
} catch {
310-
updateError(message: string.localizedErrorMessage(for: error))
329+
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
311330
throw error
312331
}
313332
}
@@ -326,7 +345,7 @@ public extension AuthService {
326345
try await user.delete()
327346
}
328347
} catch {
329-
updateError(message: string.localizedErrorMessage(for: error))
348+
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
330349
throw error
331350
}
332351
}
@@ -341,7 +360,7 @@ public extension AuthService {
341360
try await user.updatePassword(to: password)
342361
}
343362
} catch {
344-
updateError(message: string.localizedErrorMessage(for: error))
363+
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
345364
throw error
346365
}
347366
}
@@ -374,7 +393,7 @@ public extension AuthService {
374393
}
375394
} catch {
376395
authenticationState = .unauthenticated
377-
updateError(message: string.localizedErrorMessage(for: error))
396+
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
378397
throw error
379398
}
380399
}
@@ -383,7 +402,7 @@ public extension AuthService {
383402
do {
384403
try await auth.sendPasswordReset(withEmail: email)
385404
} catch {
386-
updateError(message: string.localizedErrorMessage(for: error))
405+
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
387406
throw error
388407
}
389408
}
@@ -400,7 +419,7 @@ public extension AuthService {
400419
actionCodeSettings: actionCodeSettings
401420
)
402421
} catch {
403-
updateError(message: string.localizedErrorMessage(for: error))
422+
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
404423
throw error
405424
}
406425
}
@@ -433,7 +452,7 @@ public extension AuthService {
433452
emailLink = nil
434453
}
435454
} catch {
436-
updateError(message: string.localizedErrorMessage(for: error))
455+
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
437456
throw error
438457
}
439458
}
@@ -502,7 +521,7 @@ public extension AuthService {
502521
changeRequest.photoURL = url
503522
try await changeRequest.commitChanges()
504523
} catch {
505-
updateError(message: string.localizedErrorMessage(for: error))
524+
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
506525
throw error
507526
}
508527
}
@@ -517,7 +536,7 @@ public extension AuthService {
517536
changeRequest.displayName = name
518537
try await changeRequest.commitChanges()
519538
} catch {
520-
updateError(message: string.localizedErrorMessage(for: error))
539+
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
521540
throw error
522541
}
523542
}
@@ -609,7 +628,7 @@ public extension AuthService {
609628
)
610629
}
611630
} catch {
612-
updateError(message: string.localizedErrorMessage(for: error))
631+
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
613632
throw error
614633
}
615634
}
@@ -661,7 +680,7 @@ public extension AuthService {
661680

662681
return verificationID
663682
} catch {
664-
updateError(message: string.localizedErrorMessage(for: error))
683+
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
665684
throw error
666685
}
667686
}
@@ -740,7 +759,7 @@ public extension AuthService {
740759
}
741760
currentUser = auth.currentUser
742761
} catch {
743-
updateError(message: string.localizedErrorMessage(for: error))
762+
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
744763
throw error
745764
}
746765
}
@@ -829,7 +848,7 @@ public extension AuthService {
829848

830849
return freshFactors
831850
} catch {
832-
updateError(message: string.localizedErrorMessage(for: error))
851+
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
833852
throw error
834853
}
835854
}
@@ -899,7 +918,7 @@ public extension AuthService {
899918
}
900919
}
901920
} catch {
902-
updateError(message: string.localizedErrorMessage(for: error))
921+
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
903922
throw error
904923
}
905924
}
@@ -952,7 +971,7 @@ public extension AuthService {
952971
.multiFactorAuth("Failed to resolve MFA challenge: \(error.localizedDescription)")
953972
}
954973
} catch {
955-
updateError(message: string.localizedErrorMessage(for: error))
974+
updateError(message: string.localizedErrorMessage(for: error), underlyingError: error)
956975
throw error
957976
}
958977
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,59 @@ import FirebaseAuthUIComponents
1616
import FirebaseCore
1717
import SwiftUI
1818

19+
// MARK: - Merge Conflict Handling
20+
21+
/// Helper function to handle sign-in with automatic merge conflict resolution.
22+
///
23+
/// This function attempts to sign in with the provided action. If a merge conflict occurs
24+
/// (when an anonymous user is being upgraded and the credential is already associated with
25+
/// an existing account), it automatically signs out the anonymous user and signs in with
26+
/// the existing account's credential.
27+
///
28+
/// - Parameters:
29+
/// - authService: The AuthService instance to use for sign-in operations
30+
/// - signInAction: An async closure that performs the sign-in operation
31+
/// - Returns: The SignInOutcome from the successful sign-in
32+
/// - Throws: Re-throws any errors except accountMergeConflict (which is handled internally)
33+
@MainActor
34+
public func signInWithMergeConflictHandling(authService: AuthService,
35+
signInAction: () async throws
36+
-> SignInOutcome) async throws -> SignInOutcome {
37+
do {
38+
return try await signInAction()
39+
} catch let error as AuthServiceError {
40+
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()
44+
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)
48+
}
49+
throw error
50+
}
51+
}
52+
53+
// MARK: - Environment Key for Sign-In Handler
54+
55+
/// Environment key for a sign-in handler that includes merge conflict resolution
56+
private struct SignInHandlerKey: EnvironmentKey {
57+
static let defaultValue: (@MainActor (AuthService, () async throws -> SignInOutcome) async throws
58+
-> SignInOutcome)? = nil
59+
}
60+
61+
public extension EnvironmentValues {
62+
/// A sign-in handler that automatically handles merge conflicts for anonymous user upgrades.
63+
/// When set in the environment, views should use this handler to wrap their sign-in calls.
64+
var signInWithMergeConflictHandler: (@MainActor (AuthService,
65+
() async throws -> SignInOutcome) async throws
66+
-> SignInOutcome)? {
67+
get { self[SignInHandlerKey.self] }
68+
set { self[SignInHandlerKey.self] = newValue }
69+
}
70+
}
71+
1972
@MainActor
2073
public struct AuthPickerView<Content: View> {
2174
public init(@ViewBuilder content: @escaping () -> Content = { EmptyView() }) {
@@ -54,22 +107,6 @@ extension AuthPickerView: View {
54107
MFAManagementView()
55108
case AuthView.mfaResolution:
56109
MFAResolutionView()
57-
case AuthView.enterPhoneNumber:
58-
if let phoneProvider = authService.currentPhoneProvider {
59-
EnterPhoneNumberView(phoneProvider: phoneProvider)
60-
} else {
61-
EmptyView()
62-
}
63-
case let .enterVerificationCode(verificationID, fullPhoneNumber):
64-
if let phoneProvider = authService.currentPhoneProvider {
65-
EnterVerificationCodeView(
66-
verificationID: verificationID,
67-
fullPhoneNumber: fullPhoneNumber,
68-
phoneProvider: phoneProvider
69-
)
70-
} else {
71-
EmptyView()
72-
}
73110
}
74111
}
75112
}
@@ -117,7 +154,10 @@ extension AuthPickerView: View {
117154
.aspectRatio(contentMode: .fit)
118155
.frame(width: 100, height: 100)
119156
if authService.emailSignInEnabled {
120-
EmailAuthView()
157+
EmailAuthView().environment(
158+
\.signInWithMergeConflictHandler,
159+
signInWithMergeConflictHandling
160+
)
121161
}
122162
Divider()
123163
otherSignInOptions(proxy)
@@ -133,6 +173,7 @@ extension AuthPickerView: View {
133173
authService.renderButtons()
134174
}
135175
.padding(.horizontal, proxy.size.width * 0.18)
176+
.environment(\.signInWithMergeConflictHandler, signInWithMergeConflictHandling)
136177
}
137178
}
138179

0 commit comments

Comments
 (0)