Skip to content

Commit 0a76ec8

Browse files
committed
Merge branch 'development' of https://github.com/firebase/FirebaseUI-iOS into feat/cleanup-fixes
2 parents 92b58c7 + 0a8514e commit 0a76ec8

File tree

15 files changed

+350
-131
lines changed

15 files changed

+350
-131
lines changed

FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import SwiftUI
2020
@MainActor
2121
public struct SignInWithAppleButton {
2222
@Environment(AuthService.self) private var authService
23+
@Environment(\.accountConflictHandler) private var accountConflictHandler
24+
@Environment(\.mfaHandler) private var mfaHandler
2325
@Environment(\.reportError) private var reportError
2426
let provider: AppleProviderSwift
2527
public init(provider: AppleProviderSwift) {
@@ -36,13 +38,24 @@ extension SignInWithAppleButton: View {
3638
) {
3739
Task {
3840
do {
39-
_ = try await authService.signIn(provider)
41+
let outcome = try await authService.signIn(provider)
42+
43+
// Handle MFA at view level
44+
if case let .mfaRequired(mfaInfo) = outcome,
45+
let onMFA = mfaHandler {
46+
onMFA(mfaInfo)
47+
return
48+
}
4049
} catch {
41-
if let errorHandler = reportError {
42-
errorHandler(error)
43-
} else {
44-
throw error
50+
reportError?(error)
51+
52+
if case let AuthServiceError.accountConflict(ctx) = error,
53+
let onConflict = accountConflictHandler {
54+
onConflict(ctx)
55+
return
4556
}
57+
58+
throw error
4659
}
4760
}
4861
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -136,14 +136,12 @@ public final class AuthService {
136136
public var currentMFARequired: MFARequired?
137137
private var currentMFAResolver: MultiFactorResolver?
138138

139-
/// Current account conflict context - observe this to handle conflicts and update backend
140-
public private(set) var currentAccountConflict: AccountConflictContext?
141-
142139
// MARK: - Provider APIs
143140

144141
private var listenerManager: AuthListenerManager?
145142

146143
var emailSignInEnabled = false
144+
private var emailSignInCallback: (@MainActor () -> Void)?
147145

148146
private var providers: [AuthProviderUI] = []
149147

@@ -154,12 +152,18 @@ public final class AuthService {
154152
public func renderButtons(spacing: CGFloat = 16) -> AnyView {
155153
AnyView(
156154
VStack(spacing: spacing) {
157-
AuthProviderButton(
158-
label: string.signInWithEmailLinkViewTitle,
159-
style: .email,
160-
accessibilityId: "sign-in-with-email-link-button"
161-
) {
162-
self.navigator.push(.emailLink)
155+
if emailSignInEnabled {
156+
AuthProviderButton(
157+
label: string.signInWithEmailLinkViewTitle,
158+
style: .email,
159+
accessibilityId: "sign-in-with-email-link-button"
160+
) {
161+
if let callback = self.emailSignInCallback {
162+
callback()
163+
} else {
164+
self.navigator.push(.emailLink)
165+
}
166+
}
163167
}
164168
ForEach(providers, id: \.id) { provider in
165169
provider.authButton()
@@ -189,17 +193,12 @@ public final class AuthService {
189193
}
190194

191195
public func updateAuthenticationState() {
192-
reset()
193196
authenticationState =
194197
(currentUser == nil || currentUser?.isAnonymous == true)
195198
? .unauthenticated
196199
: .authenticated
197200
}
198201

199-
func reset() {
200-
currentAccountConflict = nil
201-
}
202-
203202
public var shouldHandleAnonymousUpgrade: Bool {
204203
currentUser?.isAnonymous == true && configuration.shouldAutoUpgradeAnonymousUsers
205204
}
@@ -317,8 +316,17 @@ public extension AuthService {
317316
// MARK: - Email/Password Sign In
318317

319318
public extension AuthService {
319+
/// Enable email sign-in with default behavior (navigates to email link view)
320320
func withEmailSignIn() -> AuthService {
321+
return withEmailSignIn { [weak self] in
322+
self?.navigator.push(.emailLink)
323+
}
324+
}
325+
326+
/// Enable email sign-in with custom callback
327+
func withEmailSignIn(onTap: @escaping @MainActor () -> Void) -> AuthService {
321328
emailSignInEnabled = true
329+
emailSignInCallback = onTap
322330
return self
323331
}
324332

@@ -823,7 +831,7 @@ public extension AuthService {
823831
)
824832
}
825833

826-
/// Handles account conflict errors by creating context, storing it, and throwing structured error
834+
/// Handles account conflict errors by creating context and throwing structured error
827835
/// - Parameters:
828836
/// - error: The error to check and handle
829837
/// - credential: The credential that caused the conflict
@@ -840,10 +848,6 @@ public extension AuthService {
840848
credential: credential
841849
)
842850

843-
// Store it for consumers to observe
844-
currentAccountConflict = context
845-
846-
// Throw the specific error with context
847851
throw AuthServiceError.accountConflict(context)
848852
} else {
849853
throw error
@@ -877,7 +881,6 @@ public extension AuthService {
877881
let hints = extractMFAHints(from: resolver)
878882
currentMFARequired = MFARequired(hints: hints)
879883
currentMFAResolver = resolver
880-
navigator.push(.mfaResolution)
881884
return .mfaRequired(MFARequired(hints: hints))
882885
}
883886

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import FirebaseAuth
16+
import SwiftUI
17+
18+
/// Environment key for accessing the account conflict handler
19+
public struct AccountConflictHandlerKey: @preconcurrency EnvironmentKey {
20+
@MainActor public static let defaultValue: ((AccountConflictContext) -> Void)? = nil
21+
}
22+
23+
public extension EnvironmentValues {
24+
var accountConflictHandler: ((AccountConflictContext) -> Void)? {
25+
get { self[AccountConflictHandlerKey.self] }
26+
set { self[AccountConflictHandlerKey.self] = newValue }
27+
}
28+
}
29+
30+
/// View modifier that handles account conflicts at the view layer
31+
/// Automatically resolves anonymous upgrade conflicts and stores credentials for other conflicts
32+
@MainActor
33+
struct AccountConflictModifier: ViewModifier {
34+
@Environment(AuthService.self) private var authService
35+
@Environment(\.mfaHandler) private var mfaHandler
36+
@Environment(\.reportError) private var reportError
37+
@State private var pendingCredentialForLinking: AuthCredential?
38+
39+
func body(content: Content) -> some View {
40+
content
41+
.environment(\.accountConflictHandler, handleAccountConflict)
42+
.onChange(of: authService.authenticationState) { _, newState in
43+
// Auto-link pending credential after successful sign-in
44+
if newState == .authenticated {
45+
attemptAutoLinkPendingCredential()
46+
}
47+
}
48+
}
49+
50+
/// Handle account conflicts - auto-resolve anonymous upgrades, store others for linking
51+
func handleAccountConflict(_ conflict: AccountConflictContext) {
52+
// Only auto-handle anonymous upgrade conflicts
53+
if conflict.conflictType == .anonymousUpgradeConflict {
54+
Task {
55+
do {
56+
// Sign out the anonymous user
57+
try await authService.signOut()
58+
59+
// Sign in with the new credential
60+
let outcome = try await authService.signIn(credentials: conflict.credential)
61+
62+
// Handle MFA at view level
63+
if case let .mfaRequired(mfaInfo) = outcome,
64+
let onMFA = mfaHandler {
65+
onMFA(mfaInfo)
66+
}
67+
} catch {
68+
// Report error to parent view for display
69+
reportError?(error)
70+
}
71+
}
72+
} else {
73+
// Other conflicts: store credential for potential linking after sign-in
74+
pendingCredentialForLinking = conflict.credential
75+
// Error modal will show for user to see and handle
76+
}
77+
}
78+
79+
/// Attempt to link pending credential after successful sign-in
80+
private func attemptAutoLinkPendingCredential() {
81+
guard let credential = pendingCredentialForLinking else { return }
82+
83+
Task {
84+
do {
85+
try await authService.linkAccounts(credentials: credential)
86+
// Successfully linked, clear the pending credential
87+
pendingCredentialForLinking = nil
88+
} catch {
89+
// Silently swallow linking errors - user is already signed in
90+
pendingCredentialForLinking = nil
91+
}
92+
}
93+
}
94+
}
95+
96+
extension View {
97+
/// Adds account conflict handling to the view hierarchy
98+
/// Should be applied at the NavigationStack level to handle conflicts throughout the auth flow
99+
func accountConflictHandler() -> some View {
100+
modifier(AccountConflictModifier())
101+
}
102+
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift

Lines changed: 4 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ public struct AuthPickerView<Content: View> {
2626
@Environment(AuthService.self) private var authService
2727
private let content: () -> Content
2828

29-
// View-layer state for handling auto-linking flow
30-
@State private var pendingCredentialForLinking: AuthCredential?
3129
// View-layer error state
3230
@State private var error: AlertError?
3331
}
@@ -76,17 +74,10 @@ extension AuthPickerView: View {
7674
okButtonLabel: authService.string.okButtonLabel
7775
)
7876
.interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled)
79-
}
80-
// View-layer logic: Handle account conflicts (auto-handle anonymous upgrade, store others for
81-
// linking)
82-
.onChange(of: authService.currentAccountConflict) { _, conflict in
83-
handleAccountConflict(conflict)
84-
}
85-
// View-layer logic: Auto-link pending credential after successful sign-in
86-
.onChange(of: authService.authenticationState) { _, newState in
87-
if newState == .authenticated {
88-
attemptAutoLinkPendingCredential()
89-
}
77+
// Apply account conflict handling at NavigationStack level
78+
.accountConflictHandler()
79+
// Apply MFA handling at NavigationStack level
80+
.mfaHandler()
9081
}
9182
}
9283

@@ -100,54 +91,6 @@ extension AuthPickerView: View {
10091
}
10192
}
10293

103-
/// View-layer logic: Handle account conflicts with type-specific behavior
104-
private func handleAccountConflict(_ conflict: AccountConflictContext?) {
105-
guard let conflict = conflict else { return }
106-
107-
// Only auto-handle anonymous upgrade conflicts
108-
if conflict.conflictType == .anonymousUpgradeConflict {
109-
Task {
110-
do {
111-
// Sign out the anonymous user
112-
try await authService.signOut()
113-
114-
// Sign in with the new credential
115-
_ = try await authService.signIn(credentials: conflict.credential)
116-
117-
// Successfully handled - conflict is cleared automatically by reset()
118-
} catch let caughtError {
119-
// Show error in alert
120-
reportError(caughtError)
121-
}
122-
}
123-
} else {
124-
// Other conflicts: store credential for potential linking after sign-in
125-
pendingCredentialForLinking = conflict.credential
126-
// Show error modal for user to see and handle
127-
error = AlertError(
128-
message: conflict.message,
129-
underlyingError: conflict.underlyingError
130-
)
131-
}
132-
}
133-
134-
/// View-layer logic: Attempt to link pending credential after successful sign-in
135-
private func attemptAutoLinkPendingCredential() {
136-
guard let credential = pendingCredentialForLinking else { return }
137-
138-
Task {
139-
do {
140-
try await authService.linkAccounts(credentials: credential)
141-
// Successfully linked, clear the pending credential
142-
pendingCredentialForLinking = nil
143-
} catch let caughtError {
144-
// Show error - user is already signed in but linking failed
145-
reportError(caughtError)
146-
pendingCredentialForLinking = nil
147-
}
148-
}
149-
}
150-
15194
@ToolbarContentBuilder
15295
var toolbar: some ToolbarContent {
15396
ToolbarItem(placement: .topBarTrailing) {

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ private enum FocusableField: Hashable {
3232
@MainActor
3333
public struct EmailAuthView {
3434
@Environment(AuthService.self) private var authService
35+
@Environment(\.accountConflictHandler) private var accountConflictHandler
36+
@Environment(\.mfaHandler) private var mfaHandler
3537
@Environment(\.reportError) private var reportError
3638

3739
@State private var email = ""
@@ -54,25 +56,47 @@ public struct EmailAuthView {
5456

5557
private func signInWithEmailPassword() async throws {
5658
do {
57-
_ = try await authService.signIn(email: email, password: password)
59+
let outcome = try await authService.signIn(email: email, password: password)
60+
61+
// Handle MFA at view level
62+
if case let .mfaRequired(mfaInfo) = outcome,
63+
let onMFA = mfaHandler {
64+
onMFA(mfaInfo)
65+
return
66+
}
5867
} catch {
59-
if let errorHandler = reportError {
60-
errorHandler(error)
61-
} else {
62-
throw error
68+
reportError?(error)
69+
70+
if case let AuthServiceError.accountConflict(ctx) = error,
71+
let onConflict = accountConflictHandler {
72+
onConflict(ctx)
73+
return
6374
}
75+
76+
throw error
6477
}
6578
}
6679

6780
private func createUserWithEmailPassword() async throws {
6881
do {
69-
_ = try await authService.createUser(email: email, password: password)
82+
let outcome = try await authService.createUser(email: email, password: password)
83+
84+
// Handle MFA at view level
85+
if case let .mfaRequired(mfaInfo) = outcome,
86+
let onMFA = mfaHandler {
87+
onMFA(mfaInfo)
88+
return
89+
}
7090
} catch {
71-
if let errorHandler = reportError {
72-
errorHandler(error)
73-
} else {
74-
throw error
91+
reportError?(error)
92+
93+
if case let AuthServiceError.accountConflict(ctx) = error,
94+
let onConflict = accountConflictHandler {
95+
onConflict(ctx)
96+
return
7597
}
98+
99+
throw error
76100
}
77101
}
78102
}

0 commit comments

Comments
 (0)