diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift index 51ed3b3669a..ebd30228c99 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(\.reportError) private var reportError let provider: AppleProviderSwift public init(provider: AppleProviderSwift) { self.provider = provider @@ -34,7 +35,15 @@ extension SignInWithAppleButton: View { accessibilityId: "sign-in-with-apple-button" ) { Task { - try? await authService.signIn(provider) + do { + _ = try await authService.signIn(provider) + } catch { + if let errorHandler = reportError { + errorHandler(error) + } else { + throw error + } + } } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 417d025e9eb..0d87de6ef67 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -122,31 +122,15 @@ public final class AuthService { public let auth: Auth public var isPresented: Bool = false public private(set) var navigator = Navigator() + public var authView: AuthView? { navigator.routes.last } - var authViewRoutes: [AuthView] { - navigator.routes - } - public let string: StringUtils public var currentUser: User? public var authenticationState: AuthenticationState = .unauthenticated public var authenticationFlow: AuthenticationFlow = .signIn - private var _currentError: AlertError? - - /// A binding that allows SwiftUI views to observe and clear errors - public var currentError: Binding { - Binding( - get: { self._currentError }, - set: { newValue in - if newValue == nil { - self._currentError = nil - } - } - ) - } public let passwordPrompt: PasswordPromptCoordinator = .init() public var currentMFARequired: MFARequired? @@ -185,15 +169,9 @@ public final class AuthService { } public func signIn(_ provider: CredentialAuthProviderSwift) async throws -> SignInOutcome { - do { - let credential = try await provider.createAuthCredential() - let result = try await signIn(credentials: credential) - return result - } catch { - // Always pass the underlying error - view decides what to show - updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) - throw error - } + let credential = try await provider.createAuthCredential() + let result = try await signIn(credentials: credential) + return result } // MARK: - End Provider APIs @@ -219,28 +197,18 @@ public final class AuthService { } func reset() { - _currentError = nil currentAccountConflict = nil } - func updateError(title: String = "Error", message: String, underlyingError: Error? = nil) { - _currentError = AlertError(title: title, message: message, underlyingError: underlyingError) - } - public var shouldHandleAnonymousUpgrade: Bool { currentUser?.isAnonymous == true && configuration.shouldAutoUpgradeAnonymousUsers } 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), underlyingError: error) - throw error - } + try await auth.signOut() + // Cannot wait for auth listener to change, feedback needs to be immediate + currentUser = nil + updateAuthenticationState() } public func linkAccounts(credentials credentials: AuthCredential) async throws { @@ -307,22 +275,17 @@ public final class AuthService { } public func sendEmailVerification() async throws { - do { - if let user = currentUser { - // Requires running on MainActor as passing to sendEmailVerification() which is non-isolated - let settings: ActionCodeSettings? = await MainActor.run { - configuration.verifyEmailActionCodeSettings - } + if let user = currentUser { + // Requires running on MainActor as passing to sendEmailVerification() which is non-isolated + let settings: ActionCodeSettings? = await MainActor.run { + configuration.verifyEmailActionCodeSettings + } - if let settings = settings { - try await user.sendEmailVerification(with: settings) - } else { - try await user.sendEmailVerification() - } + if let settings = settings { + try await user.sendEmailVerification(with: settings) + } else { + try await user.sendEmailVerification() } - } catch { - updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) - throw error } } } @@ -331,32 +294,22 @@ public final class AuthService { public extension AuthService { func deleteUser() async throws { - do { - guard let user = auth.currentUser else { - throw AuthServiceError.noCurrentUser - } + guard let user = auth.currentUser else { + throw AuthServiceError.noCurrentUser + } - try await withReauthenticationIfNeeded(on: user) { - try await user.delete() - } - } catch { - updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) - throw error + try await withReauthenticationIfNeeded(on: user) { + try await user.delete() } } func updatePassword(to password: String) async throws { - do { - guard let user = auth.currentUser else { - throw AuthServiceError.noCurrentUser - } + guard let user = auth.currentUser else { + throw AuthServiceError.noCurrentUser + } - try await withReauthenticationIfNeeded(on: user) { - try await user.updatePassword(to: password) - } - } catch { - updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) - throw error + try await withReauthenticationIfNeeded(on: user) { + try await user.updatePassword(to: password) } } } @@ -395,12 +348,7 @@ public extension AuthService { } func sendPasswordRecoveryEmail(email: String) async throws { - do { - try await auth.sendPasswordReset(withEmail: email) - } catch { - updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) - throw error - } + try await auth.sendPasswordReset(withEmail: email) } } @@ -408,16 +356,11 @@ public extension AuthService { public extension AuthService { func sendEmailSignInLink(email: String) async throws { - do { - let actionCodeSettings = try updateActionCodeSettings() - try await auth.sendSignInLink( - toEmail: email, - actionCodeSettings: actionCodeSettings - ) - } catch { - updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) - throw error - } + let actionCodeSettings = try updateActionCodeSettings() + try await auth.sendSignInLink( + toEmail: email, + actionCodeSettings: actionCodeSettings + ) } func handleSignInLink(url url: URL) async throws { @@ -534,14 +477,9 @@ public extension AuthService { throw AuthServiceError.noCurrentUser } - do { - let changeRequest = user.createProfileChangeRequest() - changeRequest.photoURL = url - try await changeRequest.commitChanges() - } catch { - updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) - throw error - } + let changeRequest = user.createProfileChangeRequest() + changeRequest.photoURL = url + try await changeRequest.commitChanges() } func updateUserDisplayName(name: String) async throws { @@ -549,14 +487,9 @@ public extension AuthService { throw AuthServiceError.noCurrentUser } - do { - let changeRequest = user.createProfileChangeRequest() - changeRequest.displayName = name - try await changeRequest.commitChanges() - } catch { - updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) - throw error - } + let changeRequest = user.createProfileChangeRequest() + changeRequest.displayName = name + try await changeRequest.commitChanges() } } @@ -565,221 +498,206 @@ public extension AuthService { public extension AuthService { func startMfaEnrollment(type: SecondFactorType, accountName: String? = nil, issuer: String? = nil) async throws -> EnrollmentSession { - do { - guard let user = auth.currentUser else { - throw AuthServiceError.noCurrentUser - } + guard let user = auth.currentUser else { + throw AuthServiceError.noCurrentUser + } - // Check if MFA is enabled in configuration - guard configuration.mfaEnabled else { - throw AuthServiceError - .multiFactorAuth( - "MFA is not enabled in configuration, please enable `AuthConfiguration.mfaEnabled`" - ) - } + // Check if MFA is enabled in configuration + guard configuration.mfaEnabled else { + throw AuthServiceError + .multiFactorAuth( + "MFA is not enabled in configuration, please enable `AuthConfiguration.mfaEnabled`" + ) + } - // Check if the requested factor type is allowed - guard configuration.allowedSecondFactors.contains(type) else { - throw AuthServiceError - .multiFactorAuth( - "The requested MFA factor type '\(type)' is not allowed in AuthConfiguration.allowedSecondFactors" - ) - } + // Check if the requested factor type is allowed + guard configuration.allowedSecondFactors.contains(type) else { + throw AuthServiceError + .multiFactorAuth( + "The requested MFA factor type '\(type)' is not allowed in AuthConfiguration.allowedSecondFactors" + ) + } - let multiFactorUser = user.multiFactor + let multiFactorUser = user.multiFactor - // Get the multi-factor session - let session = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< - MultiFactorSession, - Error - >) in - multiFactorUser.getSessionWithCompletion { session, error in - if let error = error { - continuation.resume(throwing: error) - } else if let session = session { - continuation.resume(returning: session) - } else { - continuation - .resume(throwing: AuthServiceError - .multiFactorAuth("Failed to get MFA session for '\(type)'")) - } + // Get the multi-factor session + let session = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + MultiFactorSession, + Error + >) in + multiFactorUser.getSessionWithCompletion { session, error in + if let error = error { + continuation.resume(throwing: error) + } else if let session = session { + continuation.resume(returning: session) + } else { + continuation + .resume(throwing: AuthServiceError + .multiFactorAuth("Failed to get MFA session for '\(type)'")) } } + } - switch type { - case .sms: - // For SMS, we just return the session - phone number will be provided in - // sendSmsVerificationForEnrollment - return EnrollmentSession( - type: .sms, - session: session, - status: .initiated - ) + switch type { + case .sms: + // For SMS, we just return the session - phone number will be provided in + // sendSmsVerificationForEnrollment + return EnrollmentSession( + type: .sms, + session: session, + status: .initiated + ) - case .totp: - // For TOTP, generate the secret and QR code - let totpSecret = try await TOTPMultiFactorGenerator.generateSecret(with: session) + case .totp: + // For TOTP, generate the secret and QR code + let totpSecret = try await TOTPMultiFactorGenerator.generateSecret(with: session) - // Generate QR code URL - let resolvedAccountName = accountName ?? user.email ?? "User" - let resolvedIssuer = issuer ?? configuration.mfaIssuer + // Generate QR code URL + let resolvedAccountName = accountName ?? user.email ?? "User" + let resolvedIssuer = issuer ?? configuration.mfaIssuer - let qrCodeURL = totpSecret.generateQRCodeURL( - withAccountName: resolvedAccountName, - issuer: resolvedIssuer - ) + let qrCodeURL = totpSecret.generateQRCodeURL( + withAccountName: resolvedAccountName, + issuer: resolvedIssuer + ) - let totpInfo = TOTPEnrollmentInfo( - sharedSecretKey: totpSecret.sharedSecretKey(), - qrCodeURL: URL(string: qrCodeURL), - accountName: resolvedAccountName, - issuer: resolvedIssuer, - verificationStatus: .pending - ) + let totpInfo = TOTPEnrollmentInfo( + sharedSecretKey: totpSecret.sharedSecretKey(), + qrCodeURL: URL(string: qrCodeURL), + accountName: resolvedAccountName, + issuer: resolvedIssuer, + verificationStatus: .pending + ) - return EnrollmentSession( - type: .totp, - session: session, - totpInfo: totpInfo, - status: .initiated, - _totpSecret: totpSecret - ) - } - } catch { - updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) - throw error + return EnrollmentSession( + type: .totp, + session: session, + totpInfo: totpInfo, + status: .initiated, + _totpSecret: totpSecret + ) } } func sendSmsVerificationForEnrollment(session: EnrollmentSession, phoneNumber: String) async throws -> String { - do { - // Validate session - guard session.type == .sms else { - throw AuthServiceError.multiFactorAuth("Session is not configured for SMS enrollment") - } + // Validate session + guard session.type == .sms else { + throw AuthServiceError.multiFactorAuth("Session is not configured for SMS enrollment") + } - guard session.canProceed else { - if session.isExpired { - throw AuthServiceError.multiFactorAuth("Enrollment session has expired") - } else { - throw AuthServiceError - .multiFactorAuth("Session is not in a valid state for SMS verification") - } + guard session.canProceed else { + if session.isExpired { + throw AuthServiceError.multiFactorAuth("Enrollment session has expired") + } else { + throw AuthServiceError + .multiFactorAuth("Session is not in a valid state for SMS verification") } + } - // Validate phone number format - guard !phoneNumber.isEmpty else { - throw AuthServiceError.multiFactorAuth("Phone number cannot be empty for SMS enrollment") - } + // Validate phone number format + guard !phoneNumber.isEmpty else { + throw AuthServiceError.multiFactorAuth("Phone number cannot be empty for SMS enrollment") + } - // Send SMS verification using Firebase Auth PhoneAuthProvider - let verificationID = - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< - String, - Error - >) in - PhoneAuthProvider.provider().verifyPhoneNumber( - phoneNumber, - uiDelegate: nil, - multiFactorSession: session.session - ) { verificationID, error in - if let error = error { - continuation.resume(throwing: error) - } else if let verificationID = verificationID { - continuation.resume(returning: verificationID) - } else { - continuation - .resume(throwing: AuthServiceError - .multiFactorAuth("Failed to send SMS verification code to verify phone number")) - } + // Send SMS verification using Firebase Auth PhoneAuthProvider + let verificationID = + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + String, + Error + >) in + PhoneAuthProvider.provider().verifyPhoneNumber( + phoneNumber, + uiDelegate: nil, + multiFactorSession: session.session + ) { verificationID, error in + if let error = error { + continuation.resume(throwing: error) + } else if let verificationID = verificationID { + continuation.resume(returning: verificationID) + } else { + continuation + .resume(throwing: AuthServiceError + .multiFactorAuth("Failed to send SMS verification code to verify phone number")) } } + } - return verificationID - } catch { - updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) - throw error - } + return verificationID } func completeEnrollment(session: EnrollmentSession, verificationId: String?, verificationCode: String, displayName: String) async throws { - do { - // Validate session state - guard session.canProceed else { - if session.isExpired { - throw AuthServiceError - .multiFactorAuth("Enrollment session has expired, cannot complete enrollment") - } else { - throw AuthServiceError - .multiFactorAuth("Enrollment session is not in a valid state for completion") - } - } - - // Validate verification code - guard !verificationCode.isEmpty else { - throw AuthServiceError.multiFactorAuth("Verification code cannot be empty") - } - - guard let user = auth.currentUser else { - throw AuthServiceError.noCurrentUser + // Validate session state + guard session.canProceed else { + if session.isExpired { + throw AuthServiceError + .multiFactorAuth("Enrollment session has expired, cannot complete enrollment") + } else { + throw AuthServiceError + .multiFactorAuth("Enrollment session is not in a valid state for completion") } + } - let multiFactorUser = user.multiFactor + // Validate verification code + guard !verificationCode.isEmpty else { + throw AuthServiceError.multiFactorAuth("Verification code cannot be empty") + } - // Create the appropriate assertion based on factor type - let assertion: MultiFactorAssertion + guard let user = auth.currentUser else { + throw AuthServiceError.noCurrentUser + } - switch session.type { - case .sms: - // For SMS, we need the verification ID - guard let verificationId = verificationId else { - throw AuthServiceError - .multiFactorAuth("Verification ID is required for SMS enrollment") - } + let multiFactorUser = user.multiFactor - // Create phone credential and assertion - let credential = PhoneAuthProvider.provider().credential( - withVerificationID: verificationId, - verificationCode: verificationCode - ) - assertion = PhoneMultiFactorGenerator.assertion(with: credential) + // Create the appropriate assertion based on factor type + let assertion: MultiFactorAssertion - case .totp: - // For TOTP, we need the secret from the session - guard let totpInfo = session.totpInfo else { - throw AuthServiceError - .multiFactorAuth("TOTP info is missing from enrollment session") - } + switch session.type { + case .sms: + // For SMS, we need the verification ID + guard let verificationId = verificationId else { + throw AuthServiceError + .multiFactorAuth("Verification ID is required for SMS enrollment") + } - // Use the stored TOTP secret from the enrollment session - guard let secret = session._totpSecret else { - throw AuthServiceError - .multiFactorAuth("TOTP secret is missing from enrollment session") - } + // Create phone credential and assertion + let credential = PhoneAuthProvider.provider().credential( + withVerificationID: verificationId, + verificationCode: verificationCode + ) + assertion = PhoneMultiFactorGenerator.assertion(with: credential) - // The concrete type is FirebaseAuth.TOTPSecret (kept as AnyObject to avoid exposing it) - guard let totpSecret = secret as? TOTPSecret else { - throw AuthServiceError - .multiFactorAuth("Invalid TOTP secret type in enrollment session") - } + case .totp: + // For TOTP, we need the secret from the session + guard let totpInfo = session.totpInfo else { + throw AuthServiceError + .multiFactorAuth("TOTP info is missing from enrollment session") + } - assertion = TOTPMultiFactorGenerator.assertionForEnrollment( - with: totpSecret, - oneTimePassword: verificationCode - ) + // Use the stored TOTP secret from the enrollment session + guard let secret = session._totpSecret else { + throw AuthServiceError + .multiFactorAuth("TOTP secret is missing from enrollment session") } - // Complete the enrollment - try await withReauthenticationIfNeeded(on: user) { - try await user.multiFactor.enroll(with: assertion, displayName: displayName) + // The concrete type is FirebaseAuth.TOTPSecret (kept as AnyObject to avoid exposing it) + guard let totpSecret = secret as? TOTPSecret else { + throw AuthServiceError + .multiFactorAuth("Invalid TOTP secret type in enrollment session") } - currentUser = auth.currentUser - } catch { - updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) - throw error + + assertion = TOTPMultiFactorGenerator.assertionForEnrollment( + with: totpSecret, + oneTimePassword: verificationCode + ) + } + + // Complete the enrollment + try await withReauthenticationIfNeeded(on: user) { + try await user.multiFactor.enroll(with: assertion, displayName: displayName) } + currentUser = auth.currentUser } /// Gets the provider ID that was used for the current sign-in session @@ -856,26 +774,21 @@ public extension AuthService { } func unenrollMFA(_ factorUid: String) async throws -> [MultiFactorInfo] { - do { - guard let user = auth.currentUser else { - throw AuthServiceError.noCurrentUser - } + guard let user = auth.currentUser else { + throw AuthServiceError.noCurrentUser + } - let multiFactorUser = user.multiFactor + let multiFactorUser = user.multiFactor - try await withReauthenticationIfNeeded(on: user) { - try await multiFactorUser.unenroll(withFactorUID: factorUid) - } + try await withReauthenticationIfNeeded(on: user) { + try await multiFactorUser.unenroll(withFactorUID: factorUid) + } - // This is the only we to get the actual latest enrolledFactors - currentUser = Auth.auth().currentUser - let freshFactors = currentUser?.multiFactor.enrolledFactors ?? [] + // This is the only we to get the actual latest enrolledFactors + currentUser = Auth.auth().currentUser + let freshFactors = currentUser?.multiFactor.enrolledFactors ?? [] - return freshFactors - } catch { - updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) - throw error - } + return freshFactors } // MARK: - Account Conflict Helper Methods @@ -930,15 +843,9 @@ public extension AuthService { // Store it for consumers to observe currentAccountConflict = context - // Only set error alert if we're NOT auto-handling it - if conflictType != .anonymousUpgradeConflict { - updateError(message: context.message, underlyingError: error) - } - // Throw the specific error with context throw AuthServiceError.accountConflict(context) } else { - updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -975,94 +882,84 @@ public extension AuthService { } func resolveSmsChallenge(hintIndex: Int) async throws -> String { - do { - guard let resolver = currentMFAResolver else { - throw AuthServiceError.multiFactorAuth("No MFA resolver available") - } + guard let resolver = currentMFAResolver else { + throw AuthServiceError.multiFactorAuth("No MFA resolver available") + } - guard hintIndex < resolver.hints.count else { - throw AuthServiceError.multiFactorAuth("Invalid hint index") - } + guard hintIndex < resolver.hints.count else { + throw AuthServiceError.multiFactorAuth("Invalid hint index") + } - let hint = resolver.hints[hintIndex] - guard hint.factorID == PhoneMultiFactorID else { - throw AuthServiceError.multiFactorAuth("Selected hint is not a phone hint") - } - let phoneHint = hint as! PhoneMultiFactorInfo + let hint = resolver.hints[hintIndex] + guard hint.factorID == PhoneMultiFactorID else { + throw AuthServiceError.multiFactorAuth("Selected hint is not a phone hint") + } + let phoneHint = hint as! PhoneMultiFactorInfo - return try await withCheckedThrowingContinuation { continuation in - PhoneAuthProvider.provider().verifyPhoneNumber( - with: phoneHint, - uiDelegate: nil, - multiFactorSession: resolver.session - ) { verificationId, error in - if let error = error { - continuation - .resume(throwing: AuthServiceError.multiFactorAuth(error.localizedDescription)) - } else if let verificationId = verificationId { - continuation.resume(returning: verificationId) - } else { - continuation - .resume(throwing: AuthServiceError.multiFactorAuth("Unknown error occurred")) - } + return try await withCheckedThrowingContinuation { continuation in + PhoneAuthProvider.provider().verifyPhoneNumber( + with: phoneHint, + uiDelegate: nil, + multiFactorSession: resolver.session + ) { verificationId, error in + if let error = error { + continuation + .resume(throwing: AuthServiceError.multiFactorAuth(error.localizedDescription)) + } else if let verificationId = verificationId { + continuation.resume(returning: verificationId) + } else { + continuation + .resume(throwing: AuthServiceError.multiFactorAuth("Unknown error occurred")) } } - } catch { - updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) - throw error } } func resolveSignIn(code: String, hintIndex: Int, verificationId: String? = nil) async throws { - do { - guard let resolver = currentMFAResolver else { - throw AuthServiceError.multiFactorAuth("No MFA resolver available") - } + guard let resolver = currentMFAResolver else { + throw AuthServiceError.multiFactorAuth("No MFA resolver available") + } - guard hintIndex < resolver.hints.count else { - throw AuthServiceError.multiFactorAuth("Invalid hint index") - } + guard hintIndex < resolver.hints.count else { + throw AuthServiceError.multiFactorAuth("Invalid hint index") + } - let hint = resolver.hints[hintIndex] - let assertion: MultiFactorAssertion + let hint = resolver.hints[hintIndex] + let assertion: MultiFactorAssertion - // Create the appropriate assertion based on the hint type - if hint.factorID == PhoneMultiFactorID { - guard let verificationId = verificationId else { - throw AuthServiceError.multiFactorAuth("Verification ID is required for SMS MFA") - } + // Create the appropriate assertion based on the hint type + if hint.factorID == PhoneMultiFactorID { + guard let verificationId = verificationId else { + throw AuthServiceError.multiFactorAuth("Verification ID is required for SMS MFA") + } - let credential = PhoneAuthProvider.provider().credential( - withVerificationID: verificationId, - verificationCode: code - ) - assertion = PhoneMultiFactorGenerator.assertion(with: credential) + let credential = PhoneAuthProvider.provider().credential( + withVerificationID: verificationId, + verificationCode: code + ) + assertion = PhoneMultiFactorGenerator.assertion(with: credential) - } else if hint.factorID == TOTPMultiFactorID { - assertion = TOTPMultiFactorGenerator.assertionForSignIn( - withEnrollmentID: hint.uid, - oneTimePassword: code - ) + } else if hint.factorID == TOTPMultiFactorID { + assertion = TOTPMultiFactorGenerator.assertionForSignIn( + withEnrollmentID: hint.uid, + oneTimePassword: code + ) - } else { - throw AuthServiceError.multiFactorAuth("Unsupported MFA hint type") - } + } else { + throw AuthServiceError.multiFactorAuth("Unsupported MFA hint type") + } - do { - let result = try await resolver.resolveSignIn(with: assertion) - updateAuthenticationState() + do { + let result = try await resolver.resolveSignIn(with: assertion) + updateAuthenticationState() - // Clear MFA resolution state - currentMFARequired = nil - currentMFAResolver = nil + // Clear MFA resolution state + currentMFARequired = nil + currentMFAResolver = nil - } catch { - throw AuthServiceError - .multiFactorAuth("Failed to resolve MFA challenge: \(error.localizedDescription)") - } } catch { - updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) - throw error + throw AuthServiceError + .multiFactorAuth("Failed to resolve MFA challenge: \(error.localizedDescription)") } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 81d197efb7d..9bbd79a976f 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -28,6 +28,8 @@ public struct AuthPickerView { // View-layer state for handling auto-linking flow @State private var pendingCredentialForLinking: AuthCredential? + // View-layer error state + @State private var error: AlertError? } extension AuthPickerView: View { @@ -68,6 +70,11 @@ extension AuthPickerView: View { } } } + .environment(\.reportError, reportError) + .errorAlert( + error: $error, + okButtonLabel: authService.string.okButtonLabel + ) .interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled) } // View-layer logic: Handle account conflicts (auto-handle anonymous upgrade, store others for @@ -83,6 +90,16 @@ extension AuthPickerView: View { } } + /// Closure for reporting errors from child views + private func reportError(_ error: Error) { + Task { @MainActor in + self.error = AlertError( + message: authService.string.localizedErrorMessage(for: error), + underlyingError: error + ) + } + } + /// View-layer logic: Handle account conflicts with type-specific behavior private func handleAccountConflict(_ conflict: AccountConflictContext?) { guard let conflict = conflict else { return } @@ -97,16 +114,20 @@ extension AuthPickerView: View { // Sign in with the new credential _ = try await authService.signIn(credentials: conflict.credential) - // Successfully handled - conflict and error are cleared automatically by reset() - } catch { - // Error will be shown via normal error handling - // Credential is still stored if they want to retry + // Successfully handled - conflict is cleared automatically by reset() + } catch let caughtError { + // Show error in alert + reportError(caughtError) } } } else { // Other conflicts: store credential for potential linking after sign-in pendingCredentialForLinking = conflict.credential - // Error modal will show for user to see and handle + // Show error modal for user to see and handle + error = AlertError( + message: conflict.message, + underlyingError: conflict.underlyingError + ) } } @@ -119,9 +140,9 @@ extension AuthPickerView: View { try await authService.linkAccounts(credentials: credential) // Successfully linked, clear the pending credential pendingCredentialForLinking = nil - } catch { - // Silently swallow linking errors - user is already signed in - // Consumer's custom views can observe authService.currentError if they want to handle this + } catch let caughtError { + // Show error - user is already signed in but linking failed + reportError(caughtError) pendingCredentialForLinking = nil } } @@ -165,10 +186,6 @@ extension AuthPickerView: View { .background(.black.opacity(0.7)) } } - .errorAlert( - error: authService.currentError, - okButtonLabel: authService.string.okButtonLabel - ) } @ViewBuilder diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index 52fc68bcf56..bcf015d0be8 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(\.reportError) private var reportError @State private var email = "" @State private var password = "" @@ -49,12 +50,28 @@ public struct EmailAuthView { } } - private func signInWithEmailPassword() async { - try? await authService.signIn(email: email, password: password) + private func signInWithEmailPassword() async throws { + do { + _ = try await authService.signIn(email: email, password: password) + } catch { + if let errorHandler = reportError { + errorHandler(error) + } else { + throw error + } + } } - private func createUserWithEmailPassword() async { - try? await authService.createUser(email: email, password: password) + private func createUserWithEmailPassword() async throws { + do { + _ = try await authService.createUser(email: email, password: password) + } catch { + if let errorHandler = reportError { + errorHandler(error) + } else { + throw error + } + } } } @@ -83,7 +100,7 @@ extension EmailAuthView: View { contentType: .password, sensitive: true, onSubmit: { _ in - Task { await signInWithEmailPassword() } + Task { try await signInWithEmailPassword() } }, leading: { Image(systemName: "lock") @@ -110,7 +127,7 @@ extension EmailAuthView: View { contentType: .password, sensitive: true, onSubmit: { _ in - Task { await createUserWithEmailPassword() } + Task { try await createUserWithEmailPassword() } }, leading: { Image(systemName: "lock") @@ -124,9 +141,9 @@ extension EmailAuthView: View { Button(action: { Task { if authService.authenticationFlow == .signIn { - await signInWithEmailPassword() + try await signInWithEmailPassword() } else { - await createUserWithEmailPassword() + try await createUserWithEmailPassword() } } }) { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift index 14690cda82d..592e05d38d1 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift @@ -19,17 +19,22 @@ import SwiftUI public struct EmailLinkView { @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError @State private var email = "" @State private var showModal = false public init() {} - private func sendEmailLink() async { + private func sendEmailLink() async throws { do { try await authService.sendEmailSignInLink(email: email) showModal = true } catch { - // Error already displayed via modal by AuthService + if let errorHandler = reportError { + errorHandler(error) + } else { + throw error + } } } } @@ -49,7 +54,7 @@ extension EmailLinkView: View { ) Button { Task { - await sendEmailLink() + try await sendEmailLink() authService.emailLink = email } } label: { @@ -86,7 +91,15 @@ extension EmailLinkView: View { } .onOpenURL { url in Task { - try? await authService.handleSignInLink(url: url) + do { + try await authService.handleSignInLink(url: url) + } catch { + if let errorHandler = reportError { + errorHandler(error) + } else { + throw error + } + } } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift index bace46ee022..63ab35322ee 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift @@ -19,6 +19,7 @@ import SwiftUI struct EnterPhoneNumberView: View { @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError @State private var phoneNumber: String = "" @State private var selectedCountry: CountryData = .default @@ -54,7 +55,13 @@ struct EnterPhoneNumberView: View { verificationID: id, fullPhoneNumber: fullPhoneNumber )) - } catch {} + } catch { + if let errorHandler = reportError { + errorHandler(error) + } else { + throw error + } + } } }) { if authService.authenticationState == .authenticating { @@ -75,7 +82,6 @@ struct EnterPhoneNumberView: View { } .navigationTitle(authService.string.phoneSignInTitle) .padding(.horizontal) - .errorAlert(error: authService.currentError, okButtonLabel: authService.string.okButtonLabel) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift index 49e24436214..3b90934447b 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift @@ -20,6 +20,7 @@ import SwiftUI @MainActor struct EnterVerificationCodeView: View { @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError @State private var verificationCode: String = "" let verificationID: String @@ -57,7 +58,13 @@ struct EnterVerificationCodeView: View { verificationCode: verificationCode ) authService.navigator.clear() - } catch {} + } catch { + if let errorHandler = reportError { + errorHandler(error) + } else { + throw error + } + } } }) { if authService.authenticationState == .authenticating { @@ -79,7 +86,6 @@ struct EnterVerificationCodeView: View { .navigationTitle(authService.string.enterVerificationCodeTitle) .navigationBarTitleDisplayMode(.large) .padding(.horizontal) - .errorAlert(error: authService.currentError, okButtonLabel: authService.string.okButtonLabel) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift index f465cb0b22f..ec9a1f0e15d 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift @@ -83,3 +83,18 @@ public struct AlertError: Identifiable, Equatable { lhs.id == rhs.id } } + +// MARK: - Error Reporting Environment + +/// Environment key for error reporting closure +private struct ReportErrorKey: @preconcurrency EnvironmentKey { + @MainActor static let defaultValue: ((Error) -> Void)? = nil +} + +public extension EnvironmentValues { + /// Optional closure to report errors to the parent view for display + var reportError: ((Error) -> Void)? { + get { self[ReportErrorKey.self] } + set { self[ReportErrorKey.self] = newValue } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift index 2ac346b3929..ec986f55589 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift @@ -18,15 +18,20 @@ import SwiftUI @MainActor public struct SignedInView { @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError @State private var showDeleteConfirmation = false @State private var showEmailVerificationSent = false - private func sendEmailVerification() async { + private func sendEmailVerification() async throws { do { try await authService.sendEmailVerification() showEmailVerificationSent = true } catch { - // Error already displayed via modal by AuthService + if let errorHandler = reportError { + errorHandler(error) + } else { + throw error + } } } } @@ -46,7 +51,7 @@ extension SignedInView: View { if authService.currentUser?.isEmailVerified == false { Button { Task { - await sendEmailVerification() + try await sendEmailVerification() } } label: { Text(authService.string.sendEmailVerificationButtonLabel) @@ -96,7 +101,15 @@ extension SignedInView: View { Button { Task { - try? await authService.signOut() + do { + try await authService.signOut() + } catch { + if let errorHandler = reportError { + errorHandler(error) + } else { + throw error + } + } } } label: { Text(authService.string.signOutButtonLabel) @@ -114,7 +127,15 @@ extension SignedInView: View { onConfirm: { showDeleteConfirmation = false Task { - try? await authService.deleteUser() + do { + try await authService.deleteUser() + } catch { + if let errorHandler = reportError { + errorHandler(error) + } else { + throw error + } + } } }, onCancel: { diff --git a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift index a20738bf4f4..0cbbc48df94 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(\.reportError) private var reportError let facebookProvider: FacebookProviderSwift public init(facebookProvider: FacebookProviderSwift) { @@ -37,7 +38,15 @@ extension SignInWithFacebookButton: View { accessibilityId: "sign-in-with-facebook-button" ) { Task { - try? await authService.signIn(facebookProvider) + do { + _ = try await authService.signIn(facebookProvider) + } catch { + if let errorHandler = reportError { + errorHandler(error) + } else { + throw error + } + } } } } diff --git a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift index 01c9c2bac80..4ff90c0899e 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(\.reportError) private var reportError let googleProvider: GoogleProviderSwift public init(googleProvider: GoogleProviderSwift) { @@ -41,7 +42,15 @@ extension SignInWithGoogleButton: View { accessibilityId: "sign-in-with-google-button" ) { Task { - try? await authService.signIn(googleProvider) + do { + _ = try await authService.signIn(googleProvider) + } catch { + if let errorHandler = reportError { + errorHandler(error) + } else { + throw error + } + } } } } diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift index be39731482a..65e0e512417 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(\.reportError) private var reportError let provider: OAuthProviderSwift public init(provider: OAuthProviderSwift) { self.provider = provider @@ -44,7 +45,15 @@ extension GenericOAuthButton: View { accessibilityId: "sign-in-with-\(provider.providerId)-button" ) { Task { - try? await authService.signIn(provider) + do { + _ = try await authService.signIn(provider) + } catch { + if let errorHandler = reportError { + errorHandler(error) + } else { + throw error + } + } } } ) diff --git a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift index 8ffc9295ad3..e398c387df7 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(\.reportError) private var reportError let provider: TwitterProviderSwift public init(provider: TwitterProviderSwift) { self.provider = provider @@ -34,7 +35,15 @@ extension SignInWithTwitterButton: View { accessibilityId: "sign-in-with-twitter-button" ) { Task { - try? await authService.signIn(provider) + do { + _ = try await authService.signIn(provider) + } catch { + if let errorHandler = reportError { + errorHandler(error) + } else { + throw error + } + } } } } diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/ContentView.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/ContentView.swift index add1a85d3b0..2ffd030ffb2 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/ContentView.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/ContentView.swift @@ -32,6 +32,8 @@ import SwiftUI struct ContentView: View { init() { + Auth.auth().useEmulator(withHost: "localhost", port: 9099) + Auth.auth().settings?.isAppVerificationDisabledForTesting = true Auth.auth().signInAnonymously() let actionCodeSettings = ActionCodeSettings() actionCodeSettings.handleCodeInApp = true diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift index 408576e12a8..8c4dd7cb49e 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift @@ -102,7 +102,6 @@ struct FirebaseSwiftUIExampleTests { #expect(service.authenticationState == .unauthenticated) #expect(service.authView == nil) - #expect(service.currentError == nil) #expect(service.currentUser == nil) try await service.createUser(email: createEmail(), password: kPassword) @@ -116,7 +115,6 @@ struct FirebaseSwiftUIExampleTests { } #expect(service.currentUser != nil) #expect(service.authView == nil) - #expect(service.currentError == nil) } @Test @@ -137,7 +135,6 @@ struct FirebaseSwiftUIExampleTests { } #expect(service.currentUser == nil) #expect(service.authView == nil) - #expect(service.currentError == nil) try await service.signIn(email: email, password: kPassword) @@ -151,6 +148,5 @@ struct FirebaseSwiftUIExampleTests { } #expect(service.currentUser != nil) #expect(service.authView == nil) - #expect(service.currentError == nil) } }