diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift index d3c5c2151a..511582d2e8 100644 --- a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift @@ -20,6 +20,7 @@ import SwiftUI @MainActor public struct SignInWithAppleButton { @Environment(AuthService.self) private var authService + @Environment(\.signInWithMergeConflictHandler) private var signInHandler let provider: AuthProviderSwift public init(provider: AuthProviderSwift) { self.provider = provider @@ -34,7 +35,13 @@ extension SignInWithAppleButton: View { accessibilityId: "sign-in-with-apple-button" ) { Task { - try? await authService.signIn(provider) + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(provider) + } + } else { + try? await authService.signIn(provider) + } } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 8d2a75f75d..55b63c2983 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -27,9 +27,10 @@ public protocol AuthProviderUI { var provider: AuthProviderSwift { get } } -public protocol PhoneAuthProviderSwift: AuthProviderSwift { - @MainActor func verifyPhoneNumber(phoneNumber: String) async throws -> String - func setVerificationCode(verificationID: String, code: String) +public protocol PhoneAuthProviderSwift: AuthProviderSwift, AnyObject { + // Phone auth provider that presents its own UI flow in createAuthCredential() + // Internal use only: AuthService will be injected automatically by AuthService.signIn() + var authService: AuthService? { get set } } public enum AuthenticationState { @@ -50,8 +51,6 @@ public enum AuthView: Hashable { case mfaEnrollment case mfaManagement case mfaResolution - case enterPhoneNumber - case enterVerificationCode(verificationID: String, fullPhoneNumber: String) } public enum SignInOutcome: @unchecked Sendable { @@ -144,10 +143,6 @@ public final class AuthService { private var providers: [AuthProviderUI] = [] - public var currentPhoneProvider: PhoneAuthProviderSwift? { - providers.compactMap { $0.provider as? PhoneAuthProviderSwift }.first - } - public func registerProvider(providerWithButton: AuthProviderUI) { providers.append(providerWithButton) } @@ -171,11 +166,17 @@ public final class AuthService { public func signIn(_ provider: AuthProviderSwift) async throws -> SignInOutcome { do { + // Automatically inject AuthService for phone provider + if let phoneProvider = provider as? PhoneAuthProviderSwift { + phoneProvider.authService = self + } + let credential = try await provider.createAuthCredential() let result = try await signIn(credentials: credential) return result } catch { - updateError(message: string.localizedErrorMessage(for: error)) + // Always pass the underlying error - view decides what to show + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -206,8 +207,8 @@ public final class AuthService { currentError = nil } - func updateError(title: String = "Error", message: String) { - currentError = AlertError(title: title, message: message) + func updateError(title: String = "Error", message: String, underlyingError: Error? = nil) { + currentError = AlertError(title: title, message: message, underlyingError: underlyingError) } public var shouldHandleAnonymousUpgrade: Bool { @@ -217,9 +218,11 @@ public final class AuthService { public func signOut() async throws { do { try await auth.signOut() + // Cannot wait for auth listener to change, feedback needs to be immediate + currentUser = nil updateAuthenticationState() } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -237,12 +240,12 @@ public final class AuthService { updateAuthenticationState() } catch { authenticationState = .unauthenticated - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } - public func handleAutoUpgradeAnonymousUser(credentials: AuthCredential) async throws + private func handleAutoUpgradeAnonymousUser(credentials: AuthCredential) async throws -> SignInOutcome { if currentUser == nil { throw AuthServiceError.noCurrentUser @@ -252,11 +255,27 @@ public final class AuthService { updateAuthenticationState() return .signedIn(result) } catch let error as NSError { + // Handle credentialAlreadyInUse error + if error.code == AuthErrorCode.credentialAlreadyInUse.rawValue { + // Extract the updated credential from the error + let updatedCredential = error.userInfo["FIRAuthUpdatedCredentialKey"] as? AuthCredential + ?? credentials + + let context = AccountMergeConflictContext( + credential: updatedCredential, + underlyingError: error, + message: "Unable to merge accounts. The credential is already associated with a different account.", + uid: currentUser?.uid + ) + throw AuthServiceError.accountMergeConflict(context: context) + } + + // Handle emailAlreadyInUse error if error.code == AuthErrorCode.emailAlreadyInUse.rawValue { let context = AccountMergeConflictContext( credential: credentials, underlyingError: error, - message: "Unable to merge accounts. Use the credential in the context to resolve the conflict.", + message: "Unable to merge accounts. This email is already associated with a different account.", uid: currentUser?.uid ) throw AuthServiceError.accountMergeConflict(context: context) @@ -285,7 +304,7 @@ public final class AuthService { } } else { // Don't want error modal on MFA error so we only update here - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) } throw error @@ -307,7 +326,7 @@ public final class AuthService { } } } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -326,7 +345,7 @@ public extension AuthService { try await user.delete() } } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -341,7 +360,7 @@ public extension AuthService { try await user.updatePassword(to: password) } } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -374,7 +393,7 @@ public extension AuthService { } } catch { authenticationState = .unauthenticated - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -383,7 +402,7 @@ public extension AuthService { do { try await auth.sendPasswordReset(withEmail: email) } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -400,7 +419,7 @@ public extension AuthService { actionCodeSettings: actionCodeSettings ) } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -433,7 +452,7 @@ public extension AuthService { emailLink = nil } } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -502,7 +521,7 @@ public extension AuthService { changeRequest.photoURL = url try await changeRequest.commitChanges() } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -517,7 +536,7 @@ public extension AuthService { changeRequest.displayName = name try await changeRequest.commitChanges() } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -609,7 +628,7 @@ public extension AuthService { ) } } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -661,7 +680,7 @@ public extension AuthService { return verificationID } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -740,7 +759,7 @@ public extension AuthService { } currentUser = auth.currentUser } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -829,7 +848,7 @@ public extension AuthService { return freshFactors } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -899,7 +918,7 @@ public extension AuthService { } } } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -952,7 +971,7 @@ public extension AuthService { .multiFactorAuth("Failed to resolve MFA challenge: \(error.localizedDescription)") } } catch { - updateError(message: string.localizedErrorMessage(for: error)) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index e8266cc136..b8e1cdd05e 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -16,6 +16,59 @@ import FirebaseAuthUIComponents import FirebaseCore import SwiftUI +// MARK: - Merge Conflict Handling + +/// Helper function to handle sign-in with automatic merge conflict resolution. +/// +/// This function attempts to sign in with the provided action. If a merge conflict occurs +/// (when an anonymous user is being upgraded and the credential is already associated with +/// an existing account), it automatically signs out the anonymous user and signs in with +/// the existing account's credential. +/// +/// - Parameters: +/// - authService: The AuthService instance to use for sign-in operations +/// - signInAction: An async closure that performs the sign-in operation +/// - Returns: The SignInOutcome from the successful sign-in +/// - Throws: Re-throws any errors except accountMergeConflict (which is handled internally) +@MainActor +public func signInWithMergeConflictHandling(authService: AuthService, + signInAction: () async throws + -> SignInOutcome) async throws -> SignInOutcome { + do { + return try await signInAction() + } catch let error as AuthServiceError { + if case let .accountMergeConflict(context) = error { + // The anonymous account conflicts with an existing account + // Sign out the anonymous user + try await authService.signOut() + + // Sign in with the existing account's credential + // This works because shouldHandleAnonymousUpgrade is now false after sign out + return try await authService.signIn(credentials: context.credential) + } + throw error + } +} + +// MARK: - Environment Key for Sign-In Handler + +/// Environment key for a sign-in handler that includes merge conflict resolution +private struct SignInHandlerKey: EnvironmentKey { + static let defaultValue: (@MainActor (AuthService, () async throws -> SignInOutcome) async throws + -> SignInOutcome)? = nil +} + +public extension EnvironmentValues { + /// A sign-in handler that automatically handles merge conflicts for anonymous user upgrades. + /// When set in the environment, views should use this handler to wrap their sign-in calls. + var signInWithMergeConflictHandler: (@MainActor (AuthService, + () async throws -> SignInOutcome) async throws + -> SignInOutcome)? { + get { self[SignInHandlerKey.self] } + set { self[SignInHandlerKey.self] = newValue } + } +} + @MainActor public struct AuthPickerView { public init(@ViewBuilder content: @escaping () -> Content = { EmptyView() }) { @@ -54,22 +107,6 @@ extension AuthPickerView: View { MFAManagementView() case AuthView.mfaResolution: MFAResolutionView() - case AuthView.enterPhoneNumber: - if let phoneProvider = authService.currentPhoneProvider { - EnterPhoneNumberView(phoneProvider: phoneProvider) - } else { - EmptyView() - } - case let .enterVerificationCode(verificationID, fullPhoneNumber): - if let phoneProvider = authService.currentPhoneProvider { - EnterVerificationCodeView( - verificationID: verificationID, - fullPhoneNumber: fullPhoneNumber, - phoneProvider: phoneProvider - ) - } else { - EmptyView() - } } } } @@ -117,7 +154,10 @@ extension AuthPickerView: View { .aspectRatio(contentMode: .fit) .frame(width: 100, height: 100) if authService.emailSignInEnabled { - EmailAuthView() + EmailAuthView().environment( + \.signInWithMergeConflictHandler, + signInWithMergeConflictHandling + ) } Divider() otherSignInOptions(proxy) @@ -133,6 +173,7 @@ extension AuthPickerView: View { authService.renderButtons() } .padding(.horizontal, proxy.size.width * 0.18) + .environment(\.signInWithMergeConflictHandler, signInWithMergeConflictHandling) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index 58c88e89de..4bba09d475 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -32,6 +32,7 @@ private enum FocusableField: Hashable { @MainActor public struct EmailAuthView { @Environment(AuthService.self) private var authService + @Environment(\.signInWithMergeConflictHandler) private var signInHandler @State private var email = "" @State private var password = "" @@ -50,11 +51,23 @@ public struct EmailAuthView { } private func signInWithEmailPassword() async { - try? await authService.signIn(email: email, password: password) + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(email: email, password: password) + } + } else { + try? await authService.signIn(email: email, password: password) + } } private func createUserWithEmailPassword() async { - try? await authService.createUser(email: email, password: password) + if let handler = signInHandler { + try? await handler(authService) { + try await authService.createUser(email: email, password: password) + } + } else { + try? await authService.createUser(email: email, password: password) + } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift deleted file mode 100644 index 757e588f26..0000000000 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import FirebaseAuth -import FirebaseAuthUIComponents -import FirebaseCore -import SwiftUI - -@MainActor -struct EnterPhoneNumberView: View { - @Environment(AuthService.self) private var authService - @State private var phoneNumber: String = "" - @State private var selectedCountry: CountryData = .default - @State private var currentError: AlertError? = nil - @State private var isProcessing: Bool = false - - let phoneProvider: PhoneAuthProviderSwift - - var body: some View { - VStack(spacing: 16) { - Text(authService.string.enterPhoneNumberPlaceholder) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top) - - AuthTextField( - text: $phoneNumber, - localizedTitle: "Phone", - prompt: authService.string.enterPhoneNumberPlaceholder, - keyboardType: .phonePad, - contentType: .telephoneNumber, - onChange: { _ in } - ) { - CountrySelector( - selectedCountry: $selectedCountry, - enabled: !isProcessing - ) - } - - Button(action: { - Task { - isProcessing = true - do { - let fullPhoneNumber = selectedCountry.dialCode + phoneNumber - let id = try await phoneProvider.verifyPhoneNumber(phoneNumber: fullPhoneNumber) - authService.navigator.push(.enterVerificationCode( - verificationID: id, - fullPhoneNumber: fullPhoneNumber - )) - currentError = nil - } catch { - currentError = AlertError(message: error.localizedDescription) - } - isProcessing = false - } - }) { - if isProcessing { - ProgressView() - .frame(height: 32) - .frame(maxWidth: .infinity) - } else { - Text(authService.string.sendCodeButtonLabel) - .frame(height: 32) - .frame(maxWidth: .infinity) - } - } - .buttonStyle(.borderedProminent) - .disabled(isProcessing || phoneNumber.isEmpty) - .padding(.top, 8) - - Spacer() - } - .navigationTitle(authService.string.phoneSignInTitle) - .padding(.horizontal) - .errorAlert(error: $currentError, okButtonLabel: authService.string.okButtonLabel) - } -} - -#Preview { - FirebaseOptions.dummyConfigurationForPreview() - - class MockPhoneProvider: PhoneAuthProviderSwift { - var id: String = "phone" - - func verifyPhoneNumber(phoneNumber _: String) async throws -> String { - return "mock-verification-id" - } - - func setVerificationCode(verificationID _: String, code _: String) { - // Mock implementation - } - - func createAuthCredential() async throws -> AuthCredential { - fatalError("Not implemented in preview") - } - } - - return EnterPhoneNumberView(phoneProvider: MockPhoneProvider()) - .environment(AuthService()) -} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift deleted file mode 100644 index 54e4f996fb..0000000000 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import FirebaseAuth -import FirebaseAuthUIComponents -import FirebaseCore -import SwiftUI - -@MainActor -struct EnterVerificationCodeView: View { - @Environment(AuthService.self) private var authService - @Environment(\.dismiss) private var dismiss - @State private var verificationCode: String = "" - @State private var currentError: AlertError? = nil - @State private var isProcessing: Bool = false - - let verificationID: String - let fullPhoneNumber: String - let phoneProvider: PhoneAuthProviderSwift - - var body: some View { - VStack(spacing: 32) { - VStack(spacing: 16) { - VStack(spacing: 8) { - Text("We sent a code to \(fullPhoneNumber)") - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .leading) - - Button { - authService.navigator.pop() - } label: { - Text("Change number") - .font(.caption) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .padding(.bottom) - .frame(maxWidth: .infinity, alignment: .leading) - - VerificationCodeInputField( - code: $verificationCode, - isError: currentError != nil, - errorMessage: currentError?.message - ) - - Button(action: { - Task { - isProcessing = true - do { - phoneProvider.setVerificationCode( - verificationID: verificationID, - code: verificationCode - ) - let credential = try await phoneProvider.createAuthCredential() - - _ = try await authService.signIn(credentials: credential) - dismiss() - } catch { - currentError = AlertError(message: error.localizedDescription) - isProcessing = false - } - } - }) { - if isProcessing { - ProgressView() - .frame(height: 32) - .frame(maxWidth: .infinity) - } else { - Text(authService.string.verifyAndSignInButtonLabel) - .frame(height: 32) - .frame(maxWidth: .infinity) - } - } - .buttonStyle(.borderedProminent) - .disabled(isProcessing || verificationCode.count != 6) - } - - Spacer() - } - .navigationTitle(authService.string.enterVerificationCodeTitle) - .navigationBarTitleDisplayMode(.inline) - .padding(.horizontal) - .errorAlert(error: $currentError, okButtonLabel: authService.string.okButtonLabel) - } -} - -#Preview { - FirebaseOptions.dummyConfigurationForPreview() - - class MockPhoneProvider: PhoneAuthProviderSwift { - var id: String = "phone" - - func verifyPhoneNumber(phoneNumber _: String) async throws -> String { - return "mock-verification-id" - } - - func setVerificationCode(verificationID _: String, code _: String) { - // Mock implementation - } - - func createAuthCredential() async throws -> AuthCredential { - fatalError("Not implemented in preview") - } - } - - return NavigationStack { - EnterVerificationCodeView( - verificationID: "mock-id", - fullPhoneNumber: "+1 5551234567", - phoneProvider: MockPhoneProvider(), - ) - .environment(AuthService()) - } -} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift index 806fa015b3..ab1da65f8b 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift @@ -22,7 +22,11 @@ struct ErrorAlertModifier: ViewModifier { func body(content: Content) -> some View { content .alert(isPresented: Binding( - get: { error != nil }, + get: { + // View layer decides: Don't show alert for CancellationError + guard let error = error else { return false } + return !(error.underlyingError is CancellationError) + }, set: { if !$0 { error = nil } } )) { Alert( @@ -48,9 +52,11 @@ public struct AlertError: Identifiable { public let id = UUID() public let title: String public let message: String + public let underlyingError: Error? - public init(title: String = "Error", message: String) { + public init(title: String = "Error", message: String, underlyingError: Error? = nil) { self.title = title self.message = message + self.underlyingError = underlyingError } } diff --git a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift index a20738bf4f..1fd50dc472 100644 --- a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift +++ b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift @@ -22,6 +22,7 @@ import SwiftUI @MainActor public struct SignInWithFacebookButton { @Environment(AuthService.self) private var authService + @Environment(\.signInWithMergeConflictHandler) private var signInHandler let facebookProvider: FacebookProviderSwift public init(facebookProvider: FacebookProviderSwift) { @@ -37,7 +38,13 @@ extension SignInWithFacebookButton: View { accessibilityId: "sign-in-with-facebook-button" ) { Task { - try? await authService.signIn(facebookProvider) + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(facebookProvider) + } + } else { + try? await authService.signIn(facebookProvider) + } } } } diff --git a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift index dffb56123d..a4f0710cff 100644 --- a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift +++ b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift @@ -26,6 +26,7 @@ import SwiftUI @MainActor public struct SignInWithGoogleButton { @Environment(AuthService.self) private var authService + @Environment(\.signInWithMergeConflictHandler) private var signInHandler let googleProvider: AuthProviderSwift public init(googleProvider: AuthProviderSwift) { @@ -41,7 +42,13 @@ extension SignInWithGoogleButton: View { accessibilityId: "sign-in-with-google-button" ) { Task { - try? await authService.signIn(googleProvider) + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(googleProvider) + } + } else { + try? await authService.signIn(googleProvider) + } } } } diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift index b7af99e5e7..0289e16130 100644 --- a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift +++ b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift @@ -20,6 +20,7 @@ import SwiftUI @MainActor public struct GenericOAuthButton { @Environment(AuthService.self) private var authService + @Environment(\.signInWithMergeConflictHandler) private var signInHandler let provider: AuthProviderSwift public init(provider: AuthProviderSwift) { self.provider = provider @@ -51,7 +52,13 @@ extension GenericOAuthButton: View { accessibilityId: "sign-in-with-\(oauthProvider.providerId)-button" ) { Task { - try await authService.signIn(provider) + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(provider) + } + } else { + try? await authService.signIn(provider) + } } } ) diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift index 2e4e66c8f3..2f5f05bd02 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift @@ -12,44 +12,289 @@ // See the License for the specific language governing permissions and // limitations under the License. +import Combine @preconcurrency import FirebaseAuth import FirebaseAuthSwiftUI +import FirebaseAuthUIComponents import SwiftUI +import UIKit public typealias VerificationID = String -public class PhoneProviderSwift: PhoneAuthProviderSwift { - private var verificationID: String? - private var verificationCode: String? +// MARK: - Phone Auth Coordinator - public init() {} +@MainActor +private class PhoneAuthCoordinator: ObservableObject { + @Published var isPresented = true + @Published var currentStep: Step = .enterPhoneNumber + @Published var phoneNumber = "" + @Published var selectedCountry: CountryData = .default + @Published var verificationID = "" + @Published var fullPhoneNumber = "" + @Published var verificationCode = "" + @Published var currentError: AlertError? + @Published var isProcessing = false - @MainActor public func verifyPhoneNumber(phoneNumber: String) async throws -> VerificationID { - return try await withCheckedThrowingContinuation { continuation in - PhoneAuthProvider.provider() - .verifyPhoneNumber(phoneNumber, uiDelegate: nil) { verificationID, error in - if let error = error { - continuation.resume(throwing: error) - return + var continuation: CheckedContinuation? + + enum Step { + case enterPhoneNumber + case enterVerificationCode + } + + func sendVerificationCode() async { + isProcessing = true + do { + fullPhoneNumber = selectedCountry.dialCode + phoneNumber + verificationID = try await withCheckedThrowingContinuation { continuation in + PhoneAuthProvider.provider() + .verifyPhoneNumber(fullPhoneNumber, uiDelegate: nil) { verificationID, error in + if let error = error { + continuation.resume(throwing: error) + return + } + continuation.resume(returning: verificationID!) } - continuation.resume(returning: verificationID!) + } + currentStep = .enterVerificationCode + currentError = nil + } catch { + currentError = AlertError(message: error.localizedDescription) + } + isProcessing = false + } + + func verifyCodeAndComplete() async { + isProcessing = true + do { + let credential = PhoneAuthProvider.provider() + .credential(withVerificationID: verificationID, verificationCode: verificationCode) + + isPresented = false + continuation?.resume(returning: credential) + continuation = nil + } catch { + currentError = AlertError(message: error.localizedDescription) + isProcessing = false + } + } + + func cancel() { + isPresented = false + + // Only throw error if user has started the flow (sent verification code) + // If they cancel before entering/sending phone number, dismiss silently + if !verificationID.isEmpty { + continuation? + .resume(throwing: AuthServiceError.signInCancelled("Phone authentication was cancelled")) + } else { + continuation?.resume(throwing: CancellationError()) + } + + continuation = nil + } +} + +// MARK: - Phone Auth Flow View + +@MainActor +private struct PhoneAuthFlowView: View { + @StateObject var coordinator: PhoneAuthCoordinator + @Environment(AuthService.self) private var authService + + var body: some View { + NavigationStack { + Group { + switch coordinator.currentStep { + case .enterPhoneNumber: + phoneNumberView + case .enterVerificationCode: + verificationCodeView + } + } + .toolbar { + toolbar + } + } + .interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled) + } + + @ToolbarContentBuilder + var toolbar: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + if !authService.configuration.shouldHideCancelButton { + Button { + coordinator.cancel() + } label: { + Image(systemName: "xmark") + } + } + } + } + + // MARK: - Phone Number View + + var phoneNumberView: some View { + VStack(spacing: 16) { + Text(authService.string.enterPhoneNumberPlaceholder) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top) + + AuthTextField( + text: $coordinator.phoneNumber, + localizedTitle: "Phone", + prompt: authService.string.enterPhoneNumberPlaceholder, + keyboardType: .phonePad, + contentType: .telephoneNumber, + onChange: { _ in } + ) { + CountrySelector( + selectedCountry: $coordinator.selectedCountry, + enabled: !coordinator.isProcessing + ) + } + + Button(action: { + Task { + await coordinator.sendVerificationCode() } + }) { + if coordinator.isProcessing { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.sendCodeButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(coordinator.isProcessing || coordinator.phoneNumber.isEmpty) + .padding(.top, 8) + + Spacer() } + .navigationTitle(authService.string.phoneSignInTitle) + .padding(.horizontal) + .errorAlert(error: $coordinator.currentError, okButtonLabel: authService.string.okButtonLabel) } - public func setVerificationCode(verificationID: String, code: String) { - self.verificationID = verificationID - verificationCode = code + // MARK: - Verification Code View + + var verificationCodeView: some View { + VStack(spacing: 32) { + VStack(spacing: 16) { + VStack(spacing: 8) { + Text("We sent a code to \(coordinator.fullPhoneNumber)") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + coordinator.currentStep = .enterPhoneNumber + coordinator.verificationCode = "" + } label: { + Text("Change number") + .font(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.bottom) + .frame(maxWidth: .infinity, alignment: .leading) + + VerificationCodeInputField( + code: $coordinator.verificationCode, + isError: coordinator.currentError != nil, + errorMessage: coordinator.currentError?.message + ) + + Button(action: { + Task { + await coordinator.verifyCodeAndComplete() + } + }) { + if coordinator.isProcessing { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.verifyAndSignInButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(coordinator.isProcessing || coordinator.verificationCode.count != 6) + } + + Spacer() + } + .navigationTitle(authService.string.enterVerificationCodeTitle) + .navigationBarTitleDisplayMode(.inline) + .padding(.horizontal) + .errorAlert(error: $coordinator.currentError, okButtonLabel: authService.string.okButtonLabel) } +} + +// MARK: - Phone Provider Swift + +public class PhoneProviderSwift: PhoneAuthProviderSwift { + private var cancellables = Set() + + // Internal use only: Injected automatically by AuthService.signIn() + public weak var authService: AuthService? + + public init() {} @MainActor public func createAuthCredential() async throws -> AuthCredential { - guard let verificationID = verificationID, - let verificationCode = verificationCode else { - throw AuthServiceError.providerAuthenticationFailed("Verification ID or code not set") + guard let authService = authService else { + throw AuthServiceError.providerAuthenticationFailed( + "AuthService not injected. This should be set automatically by AuthService.signIn()." + ) + } + + // Get the root view controller to present from + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.windows.first?.rootViewController else { + throw AuthServiceError.rootViewControllerNotFound( + "Root view controller not available to present phone auth flow" + ) + } + + // Find the topmost view controller + var topViewController = rootViewController + while let presented = topViewController.presentedViewController { + topViewController = presented } - return PhoneAuthProvider.provider() - .credential(withVerificationID: verificationID, verificationCode: verificationCode) + // Create coordinator + let coordinator = PhoneAuthCoordinator() + + // Present the flow and wait for result + return try await withCheckedThrowingContinuation { continuation in + coordinator.continuation = continuation + + // Create SwiftUI view with environment + let flowView = PhoneAuthFlowView(coordinator: coordinator) + .environment(authService) + + let hostingController = UIHostingController(rootView: flowView) + + // Dismiss handler - watch for presentation state changes + coordinator.$isPresented.sink { [weak hostingController] isPresented in + if !isPresented { + hostingController?.dismiss(animated: true) + } + }.store(in: &cancellables) + + // Present modally + topViewController.present(hostingController, animated: true) + } } } diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift index 8af609241e..f2cd7c3ed4 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift @@ -20,6 +20,7 @@ import SwiftUI @MainActor public struct PhoneAuthButtonView { @Environment(AuthService.self) private var authService + @Environment(\.signInWithMergeConflictHandler) private var signInHandler let phoneProvider: PhoneAuthProviderSwift public init(phoneProvider: PhoneAuthProviderSwift) { @@ -34,7 +35,15 @@ extension PhoneAuthButtonView: View { style: .phone, accessibilityId: "sign-in-with-phone-button" ) { - authService.navigator.push(.enterPhoneNumber) + Task { + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(phoneProvider) + } + } else { + try? await authService.signIn(phoneProvider) + } + } } } } diff --git a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift index a874819f02..93516bc2e2 100644 --- a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift +++ b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift @@ -20,6 +20,7 @@ import SwiftUI @MainActor public struct SignInWithTwitterButton { @Environment(AuthService.self) private var authService + @Environment(\.signInWithMergeConflictHandler) private var signInHandler let provider: AuthProviderSwift public init(provider: AuthProviderSwift) { self.provider = provider @@ -34,7 +35,13 @@ extension SignInWithTwitterButton: View { accessibilityId: "sign-in-with-twitter-button" ) { Task { - try? await authService.signIn(provider) + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(provider) + } + } else { + try? await authService.signIn(provider) + } } } } diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/ContentView.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/ContentView.swift index 899361a429..0f6f583793 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/ContentView.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/ContentView.swift @@ -32,6 +32,7 @@ import SwiftUI struct ContentView: View { init() { + Auth.auth().signInAnonymously() let actionCodeSettings = ActionCodeSettings() actionCodeSettings.handleCodeInApp = true actionCodeSettings @@ -39,6 +40,7 @@ struct ContentView: View { actionCodeSettings.linkDomain = "flutterfire-e2e-tests.firebaseapp.com" actionCodeSettings.setIOSBundleID(Bundle.main.bundleIdentifier!) let configuration = AuthConfiguration( + shouldAutoUpgradeAnonymousUsers: true, tosUrl: URL(string: "https://example.com/tos"), privacyPolicyUrl: URL(string: "https://example.com/privacy"), emailLinkSignInActionCodeSettings: actionCodeSettings,