diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift index 35f23bffe9..67a9d33d5a 100644 --- a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift @@ -21,6 +21,7 @@ import SwiftUI public struct SignInWithAppleButton { @Environment(AuthService.self) private var authService @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.mfaHandler) private var mfaHandler @Environment(\.reportError) private var reportError let provider: AppleProviderSwift public init(provider: AppleProviderSwift) { @@ -37,7 +38,14 @@ extension SignInWithAppleButton: View { ) { Task { do { - _ = try await authService.signIn(provider) + let outcome = try await authService.signIn(provider) + + // Handle MFA at view level + if case let .mfaRequired(mfaInfo) = outcome, + let onMFA = mfaHandler { + onMFA(mfaInfo) + return + } } catch { reportError?(error) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index d968512db9..9f638ad913 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -141,6 +141,7 @@ public final class AuthService { private var listenerManager: AuthListenerManager? var emailSignInEnabled = false + private var emailSignInCallback: (@MainActor () -> Void)? private var providers: [AuthProviderUI] = [] @@ -151,12 +152,18 @@ public final class AuthService { public func renderButtons(spacing: CGFloat = 16) -> AnyView { AnyView( VStack(spacing: spacing) { - AuthProviderButton( - label: string.signInWithEmailLinkViewTitle, - style: .email, - accessibilityId: "sign-in-with-email-link-button" - ) { - self.navigator.push(.emailLink) + if emailSignInEnabled { + AuthProviderButton( + label: string.signInWithEmailLinkViewTitle, + style: .email, + accessibilityId: "sign-in-with-email-link-button" + ) { + if let callback = self.emailSignInCallback { + callback() + } else { + self.navigator.push(.emailLink) + } + } } ForEach(providers, id: \.id) { provider in provider.authButton() @@ -309,8 +316,17 @@ public extension AuthService { // MARK: - Email/Password Sign In public extension AuthService { + /// Enable email sign-in with default behavior (navigates to email link view) func withEmailSignIn() -> AuthService { + return withEmailSignIn { [weak self] in + self?.navigator.push(.emailLink) + } + } + + /// Enable email sign-in with custom callback + func withEmailSignIn(onTap: @escaping @MainActor () -> Void) -> AuthService { emailSignInEnabled = true + emailSignInCallback = onTap return self } @@ -865,7 +881,6 @@ public extension AuthService { let hints = extractMFAHints(from: resolver) currentMFARequired = MFARequired(hints: hints) currentMFAResolver = resolver - navigator.push(.mfaResolution) return .mfaRequired(MFARequired(hints: hints)) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AccountConflictModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AccountConflictModifier.swift index 2702670d34..5bea74ed29 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AccountConflictModifier.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AccountConflictModifier.swift @@ -32,6 +32,7 @@ public extension EnvironmentValues { @MainActor struct AccountConflictModifier: ViewModifier { @Environment(AuthService.self) private var authService + @Environment(\.mfaHandler) private var mfaHandler @Environment(\.reportError) private var reportError @State private var pendingCredentialForLinking: AuthCredential? @@ -56,7 +57,13 @@ struct AccountConflictModifier: ViewModifier { try await authService.signOut() // Sign in with the new credential - _ = try await authService.signIn(credentials: conflict.credential) + let outcome = try await authService.signIn(credentials: conflict.credential) + + // Handle MFA at view level + if case let .mfaRequired(mfaInfo) = outcome, + let onMFA = mfaHandler { + onMFA(mfaInfo) + } } catch { // Report error to parent view for display reportError?(error) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index e53e39f527..5bdf1f221a 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -76,6 +76,8 @@ extension AuthPickerView: View { .interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled) // Apply account conflict handling at NavigationStack level .accountConflictHandler() + // Apply MFA handling at NavigationStack level + .mfaHandler() } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index a0867a307e..ce0d196d7a 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -33,6 +33,7 @@ private enum FocusableField: Hashable { public struct EmailAuthView { @Environment(AuthService.self) private var authService @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.mfaHandler) private var mfaHandler @Environment(\.reportError) private var reportError @State private var email = "" @@ -53,7 +54,14 @@ public struct EmailAuthView { private func signInWithEmailPassword() async throws { do { - _ = try await authService.signIn(email: email, password: password) + let outcome = try await authService.signIn(email: email, password: password) + + // Handle MFA at view level + if case let .mfaRequired(mfaInfo) = outcome, + let onMFA = mfaHandler { + onMFA(mfaInfo) + return + } } catch { reportError?(error) @@ -69,7 +77,14 @@ public struct EmailAuthView { private func createUserWithEmailPassword() async throws { do { - _ = try await authService.createUser(email: email, password: password) + let outcome = try await authService.createUser(email: email, password: password) + + // Handle MFA at view level + if case let .mfaRequired(mfaInfo) = outcome, + let onMFA = mfaHandler { + onMFA(mfaInfo) + return + } } catch { reportError?(error) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAHandlerModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAHandlerModifier.swift new file mode 100644 index 0000000000..9c29ab8056 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAHandlerModifier.swift @@ -0,0 +1,54 @@ +// 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 SwiftUI + +/// Environment key for accessing the MFA handler +public struct MFAHandlerKey: @preconcurrency EnvironmentKey { + @MainActor public static let defaultValue: ((MFARequired) -> Void)? = nil +} + +public extension EnvironmentValues { + var mfaHandler: ((MFARequired) -> Void)? { + get { self[MFAHandlerKey.self] } + set { self[MFAHandlerKey.self] = newValue } + } +} + +/// View modifier that handles MFA requirements at the view layer +/// Automatically navigates to MFA resolution when MFA is required +@MainActor +struct MFAHandlerModifier: ViewModifier { + @Environment(AuthService.self) private var authService + + func body(content: Content) -> some View { + content + .environment(\.mfaHandler, handleMFARequired) + } + + /// Handle MFA required - navigate to MFA resolution view + func handleMFARequired(_: MFARequired) { + authService.navigator.push(.mfaResolution) + } +} + +extension View { + /// Adds MFA handling to the view hierarchy + /// Should be applied at the NavigationStack level to handle MFA requirements throughout the auth + /// flow + func mfaHandler() -> some View { + modifier(MFAHandlerModifier()) + } +} diff --git a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift index 225172534b..d30a023bfb 100644 --- a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift +++ b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift @@ -23,6 +23,7 @@ import SwiftUI public struct SignInWithFacebookButton { @Environment(AuthService.self) private var authService @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.mfaHandler) private var mfaHandler @Environment(\.reportError) private var reportError let facebookProvider: FacebookProviderSwift @@ -40,7 +41,14 @@ extension SignInWithFacebookButton: View { ) { Task { do { - _ = try await authService.signIn(facebookProvider) + let outcome = try await authService.signIn(facebookProvider) + + // Handle MFA at view level + if case let .mfaRequired(mfaInfo) = outcome, + let onMFA = mfaHandler { + onMFA(mfaInfo) + return + } } catch { reportError?(error) diff --git a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift index 7a7a3cc05b..13ed3e807c 100644 --- a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift +++ b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift @@ -27,6 +27,7 @@ import SwiftUI public struct SignInWithGoogleButton { @Environment(AuthService.self) private var authService @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.mfaHandler) private var mfaHandler @Environment(\.reportError) private var reportError let googleProvider: GoogleProviderSwift @@ -44,7 +45,14 @@ extension SignInWithGoogleButton: View { ) { Task { do { - _ = try await authService.signIn(googleProvider) + let outcome = try await authService.signIn(googleProvider) + + // Handle MFA at view level + if case let .mfaRequired(mfaInfo) = outcome, + let onMFA = mfaHandler { + onMFA(mfaInfo) + return + } } catch { reportError?(error) diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift index 2ce8ae1158..60e42ab338 100644 --- a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift +++ b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift @@ -21,6 +21,7 @@ import SwiftUI public struct GenericOAuthButton { @Environment(AuthService.self) private var authService @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.mfaHandler) private var mfaHandler @Environment(\.reportError) private var reportError let provider: OAuthProviderSwift public init(provider: OAuthProviderSwift) { @@ -47,7 +48,14 @@ extension GenericOAuthButton: View { ) { Task { do { - _ = try await authService.signIn(provider) + let outcome = try await authService.signIn(provider) + + // Handle MFA at view level + if case let .mfaRequired(mfaInfo) = outcome, + let onMFA = mfaHandler { + onMFA(mfaInfo) + return + } } catch { reportError?(error) diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/AuthService+Phone.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/AuthService+Phone.swift index b04384b49c..b3ad8bae61 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/AuthService+Phone.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/AuthService+Phone.swift @@ -22,9 +22,18 @@ import FirebaseAuthSwiftUI public extension AuthService { + /// Register phone sign-in with default behavior (navigates to enter phone number view) @discardableResult func withPhoneSignIn() -> AuthService { - registerProvider(providerWithButton: PhoneAuthProviderAuthUI()) + return withPhoneSignIn { [weak self] in + self?.navigator.push(.enterPhoneNumber) + } + } + + /// Register phone sign-in with custom behavior + @discardableResult + func withPhoneSignIn(onTap: @escaping @MainActor () -> Void) -> AuthService { + registerProvider(providerWithButton: PhoneAuthProviderAuthUI(onTap: onTap)) return self } } diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift index b4e8e63b52..c339534b6c 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift @@ -25,11 +25,15 @@ public class PhoneAuthProviderAuthUI: AuthProviderUI { public var provider: AuthProviderSwift { typedProvider } public let id: String = "phone" - public init() { + // Callback for when the phone auth button is tapped + private let onTap: @MainActor () -> Void + + public init(onTap: @escaping @MainActor () -> Void) { typedProvider = PhoneProviderSwift() + self.onTap = onTap } @MainActor public func authButton() -> AnyView { - AnyView(PhoneAuthButtonView()) + AnyView(PhoneAuthButtonView(onTap: onTap)) } } diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift index 6d2413f4d4..6b1d30ab89 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift @@ -20,8 +20,11 @@ import SwiftUI @MainActor public struct PhoneAuthButtonView { @Environment(AuthService.self) private var authService + private let onTap: @MainActor () -> Void - public init() {} + public init(onTap: @escaping @MainActor () -> Void) { + self.onTap = onTap + } } extension PhoneAuthButtonView: View { @@ -31,13 +34,15 @@ extension PhoneAuthButtonView: View { style: .phone, accessibilityId: "sign-in-with-phone-button" ) { - authService.navigator.push(.enterPhoneNumber) + onTap() } } } #Preview { FirebaseOptions.dummyConfigurationForPreview() - return PhoneAuthButtonView() - .environment(AuthService()) + return PhoneAuthButtonView { + print("Phone auth tapped") + } + .environment(AuthService()) } diff --git a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift index 0c8d89b779..2c7a75e916 100644 --- a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift +++ b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift @@ -21,6 +21,7 @@ import SwiftUI public struct SignInWithTwitterButton { @Environment(AuthService.self) private var authService @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.mfaHandler) private var mfaHandler @Environment(\.reportError) private var reportError let provider: TwitterProviderSwift public init(provider: TwitterProviderSwift) { @@ -37,7 +38,14 @@ extension SignInWithTwitterButton: View { ) { Task { do { - _ = try await authService.signIn(provider) + let outcome = try await authService.signIn(provider) + + // Handle MFA at view level + if case let .mfaRequired(mfaInfo) = outcome, + let onMFA = mfaHandler { + onMFA(mfaInfo) + return + } } catch { reportError?(error)