From e622933d598645987d6bc967e95ce0b78ea473f9 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:11:29 +0100 Subject: [PATCH 1/7] feat(auth): add content slots to email sign-in screens Add EmailAuthScreen, PasswordRecoveryScreen and EmailLinkScreen, which own the email flows' state and logic and hand their content a state struct. The stock markup moves to DefaultEmailAuthContent, DefaultPasswordRecoveryContent and DefaultEmailLinkContent, and the existing views render them through the screens. --- .../Views/DefaultEmailAuthContent.swift | 155 +++++++++++++ .../Views/DefaultEmailLinkContent.swift | 73 ++++++ .../DefaultPasswordRecoveryContent.swift | 92 ++++++++ .../Sources/Views/EmailAuthScreen.swift | 216 +++++++++++++++++ .../Sources/Views/EmailAuthView.swift | 217 +----------------- .../Sources/Views/EmailLinkScreen.swift | 167 ++++++++++++++ .../Sources/Views/EmailLinkView.swift | 91 +------- .../Views/PasswordRecoveryScreen.swift | 149 ++++++++++++ .../Sources/Views/PasswordRecoveryView.swift | 80 +------ 9 files changed, 855 insertions(+), 385 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift new file mode 100644 index 0000000000..24acc0a0c4 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift @@ -0,0 +1,155 @@ +// 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 FirebaseAuthUIComponents +import SwiftUI + +private enum FocusableField: Hashable { + case email + case password + case confirmPassword +} + +/// The stock content of ``EmailAuthScreen``. +@MainActor +public struct DefaultEmailAuthContent: View { + public init(state: EmailAuthContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + @FocusState private var focus: FocusableField? + private let state: EmailAuthContentState + + public var body: some View { + VStack(spacing: 16) { + AuthTextField( + text: state.email, + label: authService.string.emailFieldLabel, + prompt: authService.string.emailInputLabel, + keyboardType: .emailAddress, + contentType: .emailAddress, + validations: [ + FormValidators.email, + ], + maintainsValidationMessage: state.flow == .signUp, + onSubmit: { _ in + self.focus = .password + }, + leading: { + Image(systemName: "at") + } + ) + .focused($focus, equals: .email) + .accessibilityIdentifier("email-field") + AuthTextField( + text: state.password, + label: authService.string.passwordFieldLabel, + prompt: authService.string.passwordInputLabel, + contentType: .password, + isSecureTextField: true, + validations: state.flow == .signUp ? [ + FormValidators.atLeast6Characters + ] : [], + maintainsValidationMessage: state.flow == .signUp, + onSubmit: { _ in + state.signIn() + }, + leading: { + Image(systemName: "lock") + } + ) + .submitLabel(.go) + .focused($focus, equals: .password) + .accessibilityIdentifier("password-field") + if state.flow == .signIn { + Button(action: state.goToPasswordRecovery) { + Text(authService.string.passwordButtonLabel) + .authCustomFont(.body, weight: .medium) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .accessibilityIdentifier("password-recovery-button") + } + + if state.flow == .signUp { + AuthTextField( + text: state.confirmPassword, + label: authService.string.confirmPasswordFieldLabel, + prompt: authService.string.confirmPasswordInputLabel, + contentType: .password, + isSecureTextField: true, + validations: [ + FormValidators.confirmPassword(password: state.password.wrappedValue), + ], + maintainsValidationMessage: true, + onSubmit: { _ in + state.signUp() + }, + leading: { + Image(systemName: "lock") + } + ) + .submitLabel(.go) + .focused($focus, equals: .confirmPassword) + .accessibilityIdentifier("confirm-password-field") + } + + Button { + if state.flow == .signIn { + state.signIn() + } else { + state.signUp() + } + } label: { + if !state.isLoading { + Text( + state.flow == .signIn + ? authService.string.signInWithEmailButtonLabel + : authService.string.signUpWithEmailButtonLabel + ) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } else { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + } + .disabled(!state.isValid) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .authCTAButtonStyle() + .accessibilityIdentifier("sign-in-button") + } + Button(action: state.switchFlow) { + HStack(spacing: 4) { + Text( + state.flow == .signIn + ? authService.string.dontHaveAnAccountYetLabel + : authService.string.alreadyHaveAnAccountLabel + ) + .authCustomFont(.body) + .foregroundStyle(Color(.label)) + Text( + state.flow == .signUp + ? authService.string.emailLoginFlowLabel + : authService.string.emailSignUpFlowLabel + ) + .authCustomFont(.body, weight: .medium) + } + } + .accessibilityIdentifier("switch-auth-flow") + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift new file mode 100644 index 0000000000..caaa7ec17f --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift @@ -0,0 +1,73 @@ +// 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 FirebaseAuthUIComponents +import SwiftUI + +/// The stock content of ``EmailLinkScreen``. +@MainActor +public struct DefaultEmailLinkContent: View { + public init(state: EmailLinkContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: EmailLinkContentState + + public var body: some View { + VStack(spacing: 24) { + AuthTextField( + text: state.email, + label: authService.string.signInLinkEmailFieldLabel, + prompt: authService.string.emailInputLabel, + keyboardType: .emailAddress, + contentType: .emailAddress, + validations: [ + FormValidators.email, + ], + leading: { + Image(systemName: "at") + } + ) + .accessibilityIdentifier("email-link-email-field") + Button(action: state.sendSignInLink) { + Text(authService.string.sendEmailLinkButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .disabled(!state.isValid) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle(authService.string.signInWithEmailLinkViewTitle) + .safeAreaPadding() + .alert( + authService.string.signInWithEmailLinkViewTitle, + isPresented: Binding( + get: { state.didSend }, + set: { isPresented in + if !isPresented { + state.dismissAlert() + } + } + ) + ) { + Button(authService.string.okButtonLabel, action: state.dismissAlert) + } message: { + Text(authService.string.signInWithEmailLinkViewMessage) + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift new file mode 100644 index 0000000000..aa7c45ee1d --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift @@ -0,0 +1,92 @@ +// 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 FirebaseAuthUIComponents +import SwiftUI + +/// The stock content of ``PasswordRecoveryScreen``. +@MainActor +public struct DefaultPasswordRecoveryContent: View { + public init(state: PasswordRecoveryContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: PasswordRecoveryContentState + + public var body: some View { + VStack(spacing: 24) { + AuthTextField( + text: state.email, + label: authService.string.passwordRecoveryEmailFieldLabel, + prompt: authService.string.emailInputLabel, + keyboardType: .emailAddress, + contentType: .emailAddress, + validations: [ + FormValidators.email, + ], + leading: { + Image(systemName: "at") + } + ) + Button(action: state.sendResetLink) { + Text(authService.string.forgotPasswordInputLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .disabled(!state.isValid) + .frame(maxWidth: .infinity) + .authCTAButtonStyle() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle(authService.string.passwordRecoveryTitle) + .safeAreaPadding() + .sheet(isPresented: Binding( + get: { state.didSend }, + set: { isPresented in + if !isPresented { + state.dismissSuccess() + } + } + )) { + successSheet + } + } + + @ViewBuilder + @MainActor + private var successSheet: some View { + VStack { + Text(authService.string.passwordRecoveryEmailSentTitle) + .authFont(.largeTitle) + .fontWeight(.bold) + .padding() + Text(authService.string.passwordRecoveryHelperMessage) + .authCustomFont(.body) + .padding() + + Divider() + + Text(String(format: authService.string.passwordRecoveryEmailSentMessage, state.sentEmail)) + .authCustomFont(.body) + .padding() + + Divider() + + Button(authService.string.okButtonLabel, action: state.dismissSuccess) + .padding() + } + .padding() + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift new file mode 100644 index 0000000000..7e8d8cddcf --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift @@ -0,0 +1,216 @@ +// 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 FirebaseAuthUIComponents +import SwiftUI + +/// The state and actions of the email and password form, passed to ``EmailAuthScreen``'s +/// content. +public struct EmailAuthContentState { + /// The email address. The screen fills it in from ``AuthService/suggestedEmailAddress``. + public let email: Binding + + /// The password. + public let password: Binding + + /// The repeated password, checked against ``password`` when ``flow`` is + /// ``AuthenticationFlow/signUp``. + public let confirmPassword: Binding + + /// Signs in with ``email`` and ``password``, routing MFA and account conflicts to their + /// handlers. Does nothing unless ``isValid`` is `true` and ``isLoading`` is `false`. + public let signIn: @MainActor () -> Void + + /// Creates an account with ``email`` and ``password``, routing MFA and account conflicts to + /// their handlers. Does nothing unless ``isValid`` is `true` and ``isLoading`` is `false`. + public let signUp: @MainActor () -> Void + + /// Toggles ``flow`` between sign-in and sign-up, with animation. + public let switchFlow: @MainActor () -> Void + + /// Pushes ``AuthView/passwordRecovery``. + public let goToPasswordRecovery: @MainActor () -> Void + + /// Whether the form signs in or signs up. Shared with ``AuthService/authenticationFlow``. + public let flow: AuthenticationFlow + + /// Whether the fields are valid for ``flow``: a valid email and a non-empty password to sign + /// in; a valid email, a password of at least 6 characters and a matching ``confirmPassword`` + /// to sign up. + public let isValid: Bool + + /// Whether a sign-in or sign-up is in flight, or the ``AuthService`` is signing in. + public let isLoading: Bool + + /// The localized message of the last failed attempt. It clears when a field or ``flow`` + /// changes or the next attempt starts. The sheet's error alert still shows for the same + /// failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(email: Binding, + password: Binding, + confirmPassword: Binding, + signIn: @escaping @MainActor () -> Void, + signUp: @escaping @MainActor () -> Void, + switchFlow: @escaping @MainActor () -> Void, + goToPasswordRecovery: @escaping @MainActor () -> Void, + flow: AuthenticationFlow, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.email = email + self.password = password + self.confirmPassword = confirmPassword + self.signIn = signIn + self.signUp = signUp + self.switchFlow = switchFlow + self.goToPasswordRecovery = goToPasswordRecovery + self.flow = flow + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The email and password form, with its content drawn by you. +/// +/// The screen signs in and signs up, switches the flow, navigates, and routes MFA, account +/// conflicts and errors. Your content only draws the UI from the ``EmailAuthContentState`` it +/// receives. Wrap ``DefaultEmailAuthContent`` to keep the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerContent { +/// EmailAuthScreen { state in +/// TextField("Email", text: state.email) +/// SecureField("Password", text: state.password) +/// if let errorMessage = state.errorMessage { +/// Text(errorMessage) +/// } +/// Button("Sign in", action: state.signIn) +/// .disabled(!state.isValid || state.isLoading) +/// } +/// } +/// ``` +@MainActor +public struct EmailAuthScreen: View { + public init(@ViewBuilder content: @escaping (EmailAuthContentState) -> Content) { + self.content = content + } + + @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 = "" + @State private var password = "" + @State private var confirmPassword = "" + @State private var isWorking = false + @State private var errorMessage: String? + + private let content: (EmailAuthContentState) -> Content + + public var body: some View { + content(EmailAuthContentState( + email: $email, + password: $password, + confirmPassword: $confirmPassword, + signIn: signIn, + signUp: signUp, + switchFlow: switchFlow, + goToPasswordRecovery: { authService.navigator.push(.passwordRecovery) }, + flow: authService.authenticationFlow, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .onChange(of: email) { errorMessage = nil } + .onChange(of: password) { errorMessage = nil } + .onChange(of: confirmPassword) { errorMessage = nil } + .onChange(of: authService.authenticationFlow) { errorMessage = nil } + .onAppear { + if let suggestedEmailAddress = authService.suggestedEmailAddress { + email = suggestedEmailAddress + } + } + .onChange(of: authService.suggestedEmailAddress) { _, suggestedEmailAddress in + if let suggestedEmailAddress { + email = suggestedEmailAddress + } + } + } + + private var isValid: Bool { + return if authService.authenticationFlow == .signIn { + FormValidators.email.isValid(input: email) && !password.isEmpty + } else { + FormValidators.email.isValid(input: email) && + FormValidators.atLeast6Characters.isValid(input: password) && + FormValidators.confirmPassword(password: password).isValid(input: confirmPassword) + } + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func switchFlow() { + withAnimation { + authService.authenticationFlow = + authService.authenticationFlow == .signIn ? .signUp : .signIn + } + } + + private func signIn() { + submit { [email, password] in + try await authService.signIn(email: email, password: password) + } + } + + private func signUp() { + submit { [email, password] in + try await authService.createUser(email: email, password: password) + } + } + + private func submit(_ attempt: @escaping @MainActor () async throws -> SignInOutcome) { + guard isValid, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + if case let .mfaRequired(mfaInfo) = try await attempt() { + mfaHandler?(mfaInfo) + } + } catch { + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return + } + + reportError?(error) + // The legacy sign-in recovery sheet explains this failure, so there is no message. + if case .legacySignInRecoveryPresented = error as? AuthServiceError { + return + } + + errorMessage = authService.string.localizedErrorMessage(for: error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index 7d06240f4a..96cc6a6961 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -18,232 +18,17 @@ // // Created by Russell Wheatley on 20/03/2025. // -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI -private enum FocusableField: Hashable { - case email - case password - case confirmPassword -} - @MainActor 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 = "" - @State private var password = "" - @State private var confirmPassword = "" - - @FocusState private var focus: FocusableField? - public init() {} - - private var isValid: Bool { - return if authService.authenticationFlow == .signIn { - FormValidators.email.isValid(input: email) && !password.isEmpty - } else { - FormValidators.email.isValid(input: email) && - FormValidators.atLeast6Characters.isValid(input: password) && - FormValidators.confirmPassword(password: password).isValid(input: confirmPassword) - } - } - - private func signInWithEmailPassword() async throws { - do { - 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 { - if case let AuthServiceError.accountConflict(ctx) = error, - let onConflict = accountConflictHandler { - onConflict(ctx) - return - } - - reportError?(error) - if case .legacySignInRecoveryPresented = error as? AuthServiceError { - return - } - - throw error - } - } - - private func createUserWithEmailPassword() async throws { - do { - 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 { - if case let AuthServiceError.accountConflict(ctx) = error, - let onConflict = accountConflictHandler { - onConflict(ctx) - return - } - - reportError?(error) - throw error - } - } } extension EmailAuthView: View { public var body: some View { - VStack(spacing: 16) { - AuthTextField( - text: $email, - label: authService.string.emailFieldLabel, - prompt: authService.string.emailInputLabel, - keyboardType: .emailAddress, - contentType: .emailAddress, - validations: [ - FormValidators.email, - ], - maintainsValidationMessage: authService.authenticationFlow == .signUp, - onSubmit: { _ in - self.focus = .password - }, - leading: { - Image(systemName: "at") - } - ) - .focused($focus, equals: .email) - .accessibilityIdentifier("email-field") - AuthTextField( - text: $password, - label: authService.string.passwordFieldLabel, - prompt: authService.string.passwordInputLabel, - contentType: .password, - isSecureTextField: true, - validations: authService.authenticationFlow == .signUp ? [ - FormValidators.atLeast6Characters - ] : [], - maintainsValidationMessage: authService.authenticationFlow == .signUp, - onSubmit: { _ in - Task { try await signInWithEmailPassword() } - }, - leading: { - Image(systemName: "lock") - } - ) - .submitLabel(.go) - .focused($focus, equals: .password) - .accessibilityIdentifier("password-field") - if authService.authenticationFlow == .signIn { - Button { - authService.navigator.push(.passwordRecovery) - } label: { - Text(authService.string.passwordButtonLabel) - .authCustomFont(.body, weight: .medium) - .frame(maxWidth: .infinity, alignment: .trailing) - } - .accessibilityIdentifier("password-recovery-button") - } - - if authService.authenticationFlow == .signUp { - AuthTextField( - text: $confirmPassword, - label: authService.string.confirmPasswordFieldLabel, - prompt: authService.string.confirmPasswordInputLabel, - contentType: .password, - isSecureTextField: true, - validations: [ - FormValidators.confirmPassword(password: password), - ], - maintainsValidationMessage: true, - onSubmit: { _ in - Task { try await createUserWithEmailPassword() } - }, - leading: { - Image(systemName: "lock") - } - ) - .submitLabel(.go) - .focused($focus, equals: .confirmPassword) - .accessibilityIdentifier("confirm-password-field") - } - - Button(action: { - Task { - if authService.authenticationFlow == .signIn { - try await signInWithEmailPassword() - } else { - try await createUserWithEmailPassword() - } - } - }) { - if authService.authenticationState != .authenticating { - Text( - authService.authenticationFlow == .signIn - ? authService.string.signInWithEmailButtonLabel - : authService.string.signUpWithEmailButtonLabel - ) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } else { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - } - .disabled(!isValid) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .authCTAButtonStyle() - .accessibilityIdentifier("sign-in-button") - } - Button(action: { - withAnimation { - authService.authenticationFlow = - authService - .authenticationFlow == .signIn ? .signUp : .signIn - } - }) { - HStack(spacing: 4) { - Text( - authService - .authenticationFlow == .signIn - ? authService.string.dontHaveAnAccountYetLabel - : authService.string.alreadyHaveAnAccountLabel - ) - .authCustomFont(.body) - .foregroundStyle(Color(.label)) - Text( - authService.authenticationFlow == .signUp - ? authService.string.emailLoginFlowLabel - : authService.string.emailSignUpFlowLabel - ) - .authCustomFont(.body, weight: .medium) - } - } - .accessibilityIdentifier("switch-auth-flow") - .onAppear { - if let suggestedEmailAddress = authService.suggestedEmailAddress { - email = suggestedEmailAddress - } - } - .onChange(of: authService.suggestedEmailAddress) { _, suggestedEmailAddress in - if let suggestedEmailAddress { - email = suggestedEmailAddress - } - } + EmailAuthScreen { DefaultEmailAuthContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift new file mode 100644 index 0000000000..206cf50ca8 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift @@ -0,0 +1,167 @@ +// 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 SwiftUI + +/// The state and actions of the email-link screen, passed to ``EmailLinkScreen``'s content. +public struct EmailLinkContentState { + /// The email address to send the sign-in link to. The screen fills it in from + /// ``AuthService/suggestedEmailAddress``. + public let email: Binding + + /// Sends a sign-in link to ``email``, then sets ``didSend``. Does nothing unless ``isValid`` is + /// `true` and ``isLoading`` is `false`. + public let sendSignInLink: @MainActor () -> Void + + /// Clears ``didSend``. + public let dismissAlert: @MainActor () -> Void + + /// Whether a sign-in link was sent and ``dismissAlert`` hasn't been called since. + public let didSend: Bool + + /// Whether ``email`` is a valid email address. + public let isValid: Bool + + /// Whether a link is being sent, or the ``AuthService`` is signing in. + public let isLoading: Bool + + /// The localized message of the last failed send or link sign-in. It clears when the email + /// changes or the next attempt starts. The sheet's error alert still shows for the same + /// failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(email: Binding, + sendSignInLink: @escaping @MainActor () -> Void, + dismissAlert: @escaping @MainActor () -> Void, + didSend: Bool, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.email = email + self.sendSignInLink = sendSignInLink + self.dismissAlert = dismissAlert + self.didSend = didSend + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The email-link screen, with its content drawn by you. +/// +/// The screen sends the link, signs in when the app opens it, and routes account conflicts and +/// errors. Your content only draws the UI from the ``EmailLinkContentState`` it receives. Wrap +/// ``DefaultEmailLinkContent`` to keep the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case .emailLink: +/// EmailLinkScreen { state in +/// TextField("Email", text: state.email) +/// Button("Send link", action: state.sendSignInLink) +/// .disabled(!state.isValid || state.isLoading) +/// if state.didSend { +/// Text("Check your inbox") +/// } +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct EmailLinkScreen: View { + public init(@ViewBuilder content: @escaping (EmailLinkContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.reportError) private var reportError + @State private var email = "" + @State private var didSend = false + @State private var isWorking = false + @State private var errorMessage: String? + + private let content: (EmailLinkContentState) -> Content + + public var body: some View { + content(EmailLinkContentState( + email: $email, + sendSignInLink: sendSignInLink, + dismissAlert: { didSend = false }, + didSend: didSend, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .onChange(of: email) { errorMessage = nil } + .onOpenURL { url in + errorMessage = nil + Task { + do { + try await authService.handleSignInLink(url: url) + } catch { + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return + } + + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } + .onAppear { + if let suggestedEmailAddress = authService.suggestedEmailAddress { + email = suggestedEmailAddress + } + } + .onChange(of: authService.suggestedEmailAddress) { _, suggestedEmailAddress in + if let suggestedEmailAddress { + email = suggestedEmailAddress + } + } + } + + private var isValid: Bool { + CommonUtils.isValidEmail(email) + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func sendSignInLink() { + guard isValid, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + try await authService.sendEmailSignInLink(email: email) + didSend = true + authService.emailLink = email + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift index c64c86e570..c5b8e776aa 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift @@ -12,106 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor public struct EmailLinkView { - @Environment(AuthService.self) private var authService - @Environment(\.accountConflictHandler) private var accountConflictHandler - @Environment(\.reportError) private var reportError - @State private var email = "" - @State private var showAlert = false - public init() {} - - private func sendEmailLink() async throws { - do { - try await authService.sendEmailSignInLink(email: email) - showAlert = true - } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error - } - } - } } extension EmailLinkView: View { public var body: some View { - VStack(spacing: 24) { - AuthTextField( - text: $email, - label: authService.string.signInLinkEmailFieldLabel, - prompt: authService.string.emailInputLabel, - keyboardType: .emailAddress, - contentType: .emailAddress, - validations: [ - FormValidators.email, - ], - leading: { - Image(systemName: "at") - } - ) - .accessibilityIdentifier("email-link-email-field") - Button { - Task { - try await sendEmailLink() - authService.emailLink = email - } - } label: { - Text(authService.string.sendEmailLinkButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .disabled(!CommonUtils.isValidEmail(email)) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .navigationTitle(authService.string.signInWithEmailLinkViewTitle) - .safeAreaPadding() - .alert( - authService.string.signInWithEmailLinkViewTitle, - isPresented: $showAlert - ) { - Button(authService.string.okButtonLabel) { - showAlert = false - } - } message: { - Text(authService.string.signInWithEmailLinkViewMessage) - } - .onOpenURL { url in - Task { - do { - try await authService.handleSignInLink(url: url) - } catch { - if case let AuthServiceError.accountConflict(ctx) = error, - let onConflict = accountConflictHandler { - onConflict(ctx) - return - } - - reportError?(error) - throw error - } - } - } - .onAppear { - if let suggestedEmailAddress = authService.suggestedEmailAddress { - email = suggestedEmailAddress - } - } - .onChange(of: authService.suggestedEmailAddress) { _, suggestedEmailAddress in - if let suggestedEmailAddress { - email = suggestedEmailAddress - } - } + EmailLinkScreen { DefaultEmailLinkContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift new file mode 100644 index 0000000000..df201b6975 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift @@ -0,0 +1,149 @@ +// 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 SwiftUI + +/// The state and actions of the password recovery screen, passed to +/// ``PasswordRecoveryScreen``'s content. +public struct PasswordRecoveryContentState { + /// The email address to send the reset link to. + public let email: Binding + + /// Sends a password reset link to ``email``, then sets ``didSend``. Does nothing unless + /// ``isValid`` is `true` and ``isLoading`` is `false`. + public let sendResetLink: @MainActor () -> Void + + /// Clears ``didSend`` and ``email`` and returns to the first screen of the sheet. + public let dismissSuccess: @MainActor () -> Void + + /// Whether a reset link was sent and ``dismissSuccess`` hasn't been called since. + public let didSend: Bool + + /// The address the last reset link was sent to; empty until one is sent. + public let sentEmail: String + + /// Whether ``email`` is a valid email address. + public let isValid: Bool + + /// Whether a reset link is being sent. + public let isLoading: Bool + + /// The localized message of the last failed attempt. It clears when the email changes or the + /// next attempt starts. The sheet's error alert still shows for the same failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(email: Binding, + sendResetLink: @escaping @MainActor () -> Void, + dismissSuccess: @escaping @MainActor () -> Void, + didSend: Bool, + sentEmail: String, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.email = email + self.sendResetLink = sendResetLink + self.dismissSuccess = dismissSuccess + self.didSend = didSend + self.sentEmail = sentEmail + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The password recovery screen, with its content drawn by you. +/// +/// The screen sends the reset link, navigates, and reports errors. Your content only draws the +/// UI from the ``PasswordRecoveryContentState`` it receives. Wrap +/// ``DefaultPasswordRecoveryContent`` to keep the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case .passwordRecovery: +/// PasswordRecoveryScreen { state in +/// if state.didSend { +/// Text("Check \(state.sentEmail)") +/// Button("OK", action: state.dismissSuccess) +/// } else { +/// TextField("Email", text: state.email) +/// Button("Send reset link", action: state.sendResetLink) +/// .disabled(!state.isValid || state.isLoading) +/// } +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct PasswordRecoveryScreen: View { + public init(@ViewBuilder content: @escaping (PasswordRecoveryContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var email = "" + @State private var didSend = false + @State private var sentEmail = "" + @State private var isWorking = false + @State private var errorMessage: String? + + private let content: (PasswordRecoveryContentState) -> Content + + public var body: some View { + content(PasswordRecoveryContentState( + email: $email, + sendResetLink: sendResetLink, + dismissSuccess: dismissSuccess, + didSend: didSend, + sentEmail: sentEmail, + isValid: isValid, + isLoading: isWorking, + errorMessage: errorMessage + )) + .onChange(of: email) { errorMessage = nil } + } + + private var isValid: Bool { + CommonUtils.isValidEmail(email) + } + + private func sendResetLink() { + guard isValid, !isWorking else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + try await authService.sendPasswordRecoveryEmail(email: email) + sentEmail = email + didSend = true + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } + + private func dismissSuccess() { + didSend = false + email = "" + authService.navigator.clear() + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift index 73a60758e6..93a9a4f5af 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift @@ -12,95 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor public struct PasswordRecoveryView { - @Environment(AuthService.self) private var authService - @State private var email = "" - @State private var showSuccessSheet = false - @State private var sentEmail = "" - public init() {} - - private func sendPasswordRecoveryEmail() async { - do { - try await authService.sendPasswordRecoveryEmail(email: email) - sentEmail = email - showSuccessSheet = true - } catch { - // Error already displayed via modal by AuthService - } - } } extension PasswordRecoveryView: View { public var body: some View { - VStack(spacing: 24) { - AuthTextField( - text: $email, - label: authService.string.passwordRecoveryEmailFieldLabel, - prompt: authService.string.emailInputLabel, - keyboardType: .emailAddress, - contentType: .emailAddress, - validations: [ - FormValidators.email, - ], - leading: { - Image(systemName: "at") - } - ) - Button(action: { - Task { - await sendPasswordRecoveryEmail() - } - }) { - Text(authService.string.forgotPasswordInputLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .disabled(!CommonUtils.isValidEmail(email)) - .frame(maxWidth: .infinity) - .authCTAButtonStyle() - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .navigationTitle(authService.string.passwordRecoveryTitle) - .safeAreaPadding() - .sheet(isPresented: $showSuccessSheet) { - successSheet - } - } - - @ViewBuilder - @MainActor - private var successSheet: some View { - VStack { - Text(authService.string.passwordRecoveryEmailSentTitle) - .authFont(.largeTitle) - .fontWeight(.bold) - .padding() - Text(authService.string.passwordRecoveryHelperMessage) - .authCustomFont(.body) - .padding() - - Divider() - - Text(String(format: authService.string.passwordRecoveryEmailSentMessage, sentEmail)) - .authCustomFont(.body) - .padding() - - Divider() - - Button(authService.string.okButtonLabel) { - showSuccessSheet = false - email = "" - authService.navigator.clear() - } - .padding() - } - .padding() + PasswordRecoveryScreen { DefaultPasswordRecoveryContent(state: $0) } } } From 3e4ebdb5c8370880dc941ca3b094276154542424 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:31:36 +0100 Subject: [PATCH 2/7] fix(auth): address review of the email content slots Add goToEmailLink and hideSuccess to the state structs, keep the recovery screen when its success sheet is swiped away, handle an opened email link once even when custom content has several root views, move focus to confirm password on Go during sign-up, and align the recovery screen's isLoading with the other screens. --- .../Views/DefaultEmailAuthContent.swift | 6 +++- .../Views/DefaultEmailLinkContent.swift | 2 +- .../DefaultPasswordRecoveryContent.swift | 2 +- .../Sources/Views/EmailAuthScreen.swift | 28 ++++++++++++++----- .../Sources/Views/EmailLinkScreen.swift | 10 +++++-- .../Views/PasswordRecoveryScreen.swift | 20 ++++++++++--- 6 files changed, 52 insertions(+), 16 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift index 24acc0a0c4..c6a63a756c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift @@ -64,7 +64,11 @@ public struct DefaultEmailAuthContent: View { ] : [], maintainsValidationMessage: state.flow == .signUp, onSubmit: { _ in - state.signIn() + if state.flow == .signIn { + state.signIn() + } else { + focus = .confirmPassword + } }, leading: { Image(systemName: "lock") diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift index caaa7ec17f..6955b65dcd 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift @@ -65,7 +65,7 @@ public struct DefaultEmailLinkContent: View { } ) ) { - Button(authService.string.okButtonLabel, action: state.dismissAlert) + Button(authService.string.okButtonLabel) {} } message: { Text(authService.string.signInWithEmailLinkViewMessage) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift index aa7c45ee1d..f2fcbdfa84 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift @@ -56,7 +56,7 @@ public struct DefaultPasswordRecoveryContent: View { get: { state.didSend }, set: { isPresented in if !isPresented { - state.dismissSuccess() + state.hideSuccess() } } )) { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift index 7e8d8cddcf..1ef80fd4f4 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift @@ -42,6 +42,9 @@ public struct EmailAuthContentState { /// Pushes ``AuthView/passwordRecovery``. public let goToPasswordRecovery: @MainActor () -> Void + /// Starts email-link sign-in, the same as the stock email-link button. + public let goToEmailLink: @MainActor () -> Void + /// Whether the form signs in or signs up. Shared with ``AuthService/authenticationFlow``. public let flow: AuthenticationFlow @@ -66,6 +69,7 @@ public struct EmailAuthContentState { signUp: @escaping @MainActor () -> Void, switchFlow: @escaping @MainActor () -> Void, goToPasswordRecovery: @escaping @MainActor () -> Void, + goToEmailLink: @escaping @MainActor () -> Void, flow: AuthenticationFlow, isValid: Bool, isLoading: Bool, @@ -77,6 +81,7 @@ public struct EmailAuthContentState { self.signUp = signUp self.switchFlow = switchFlow self.goToPasswordRecovery = goToPasswordRecovery + self.goToEmailLink = goToEmailLink self.flow = flow self.isValid = isValid self.isLoading = isLoading @@ -90,18 +95,26 @@ public struct EmailAuthContentState { /// conflicts and errors. Your content only draws the UI from the ``EmailAuthContentState`` it /// receives. Wrap ``DefaultEmailAuthContent`` to keep the stock markup. /// +/// ``AuthPickerView/pickerContent(_:)`` replaces the whole first screen, so add the provider +/// buttons back with ``DefaultAuthMethodPicker``. +/// /// Example: /// ```swift /// AuthPickerView { authenticatedApp } /// .pickerContent { -/// EmailAuthScreen { state in -/// TextField("Email", text: state.email) -/// SecureField("Password", text: state.password) -/// if let errorMessage = state.errorMessage { -/// Text(errorMessage) +/// ScrollView { +/// VStack { +/// EmailAuthScreen { state in +/// TextField("Email", text: state.email) +/// SecureField("Password", text: state.password) +/// if let errorMessage = state.errorMessage { +/// Text(errorMessage) +/// } +/// Button("Sign in", action: state.signIn) +/// .disabled(!state.isValid || state.isLoading) +/// } +/// DefaultAuthMethodPicker() /// } -/// Button("Sign in", action: state.signIn) -/// .disabled(!state.isValid || state.isLoading) /// } /// } /// ``` @@ -132,6 +145,7 @@ public struct EmailAuthScreen: View { signUp: signUp, switchFlow: switchFlow, goToPasswordRecovery: { authService.navigator.push(.passwordRecovery) }, + goToEmailLink: { authService.selectEmailLink() }, flow: authService.authenticationFlow, isValid: isValid, isLoading: isLoading, diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift index 206cf50ca8..11577d6902 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift @@ -33,7 +33,7 @@ public struct EmailLinkContentState { /// Whether ``email`` is a valid email address. public let isValid: Bool - /// Whether a link is being sent, or the ``AuthService`` is signing in. + /// Whether a link is being sent or handled, or the ``AuthService`` is signing in. public let isLoading: Bool /// The localized message of the last failed send or link sign-in. It clears when the email @@ -96,6 +96,7 @@ public struct EmailLinkScreen: View { @State private var email = "" @State private var didSend = false @State private var isWorking = false + @State private var isHandlingLink = false @State private var errorMessage: String? private let content: (EmailLinkContentState) -> Content @@ -112,8 +113,13 @@ public struct EmailLinkScreen: View { )) .onChange(of: email) { errorMessage = nil } .onOpenURL { url in + // Content with several root views gets this handler once per view; the link is one-time, + // so only the first one handles it. + guard !isHandlingLink else { return } + isHandlingLink = true errorMessage = nil Task { + defer { isHandlingLink = false } do { try await authService.handleSignInLink(url: url) } catch { @@ -145,7 +151,7 @@ public struct EmailLinkScreen: View { } private var isLoading: Bool { - isWorking || authService.authenticationState == .authenticating + isWorking || isHandlingLink || authService.authenticationState == .authenticating } private func sendSignInLink() { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift index df201b6975..23f1f95abc 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift @@ -27,7 +27,12 @@ public struct PasswordRecoveryContentState { /// Clears ``didSend`` and ``email`` and returns to the first screen of the sheet. public let dismissSuccess: @MainActor () -> Void - /// Whether a reset link was sent and ``dismissSuccess`` hasn't been called since. + /// Clears ``didSend``, keeping ``email`` and staying on this screen, for example when the + /// user swipes a success sheet away. + public let hideSuccess: @MainActor () -> Void + + /// Whether a reset link was sent and neither ``dismissSuccess`` nor ``hideSuccess`` has been + /// called since. public let didSend: Bool /// The address the last reset link was sent to; empty until one is sent. @@ -36,7 +41,7 @@ public struct PasswordRecoveryContentState { /// Whether ``email`` is a valid email address. public let isValid: Bool - /// Whether a reset link is being sent. + /// Whether a reset link is being sent, or the ``AuthService`` is signing in. public let isLoading: Bool /// The localized message of the last failed attempt. It clears when the email changes or the @@ -47,6 +52,7 @@ public struct PasswordRecoveryContentState { public init(email: Binding, sendResetLink: @escaping @MainActor () -> Void, dismissSuccess: @escaping @MainActor () -> Void, + hideSuccess: @escaping @MainActor () -> Void, didSend: Bool, sentEmail: String, isValid: Bool, @@ -55,6 +61,7 @@ public struct PasswordRecoveryContentState { self.email = email self.sendResetLink = sendResetLink self.dismissSuccess = dismissSuccess + self.hideSuccess = hideSuccess self.didSend = didSend self.sentEmail = sentEmail self.isValid = isValid @@ -111,10 +118,11 @@ public struct PasswordRecoveryScreen: View { email: $email, sendResetLink: sendResetLink, dismissSuccess: dismissSuccess, + hideSuccess: { didSend = false }, didSend: didSend, sentEmail: sentEmail, isValid: isValid, - isLoading: isWorking, + isLoading: isLoading, errorMessage: errorMessage )) .onChange(of: email) { errorMessage = nil } @@ -124,8 +132,12 @@ public struct PasswordRecoveryScreen: View { CommonUtils.isValidEmail(email) } + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + private func sendResetLink() { - guard isValid, !isWorking else { return } + guard isValid, !isLoading else { return } isWorking = true errorMessage = nil Task { From 05d3f96a9e21b98b575c87380dc026cdf6294058 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:47:13 +0100 Subject: [PATCH 3/7] docs(auth): point email screen docs at the whole-screen pickerContent --- .../FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift | 6 +++--- .../FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index db685ca1fc..daaf2266e2 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -71,9 +71,9 @@ public struct AuthPickerView: View { /// account conflicts and errors are handled the same as the default buttons. /// /// Your screen replaces the logo, email sign-in, terms and the large "Sign in" title (the close - /// button stays), so show your own heading, email entry (for example one built around - /// ``EmailAuthView``) and any legal text you need. To keep the stock screen and only lay out - /// the provider buttons, use ``DefaultAuthPickerContent/init(authMethodPicker:)``. + /// button stays), so show your own heading, email entry (for example ``EmailAuthScreen``) and + /// any legal text you need. To keep the stock screen and only lay out the provider buttons, use + /// ``DefaultAuthPickerContent/init(authMethodPicker:)``. /// /// Example: /// ```swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift index 1ef80fd4f4..c14261e320 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift @@ -95,8 +95,9 @@ public struct EmailAuthContentState { /// conflicts and errors. Your content only draws the UI from the ``EmailAuthContentState`` it /// receives. Wrap ``DefaultEmailAuthContent`` to keep the stock markup. /// -/// ``AuthPickerView/pickerContent(_:)`` replaces the whole first screen, so add the provider -/// buttons back with ``DefaultAuthMethodPicker``. +/// `AuthPickerView`'s `pickerContent { state in ... }` replaces the whole first screen, so add +/// your own provider buttons calling `state.select`, or the stock ones with +/// ``DefaultAuthMethodPicker``. /// /// Example: /// ```swift From 848764a300a4d167927d0ed2630aaba6d66ba146 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:20:57 +0100 Subject: [PATCH 4/7] feat(auth): add a signUp route for a pushed email sign-up screen --- .../Sources/Services/AuthService.swift | 2 + .../Views/DefaultAuthPickerDestination.swift | 7 ++++ .../Sources/Views/EmailAuthScreen.swift | 38 ++++++++++++++++--- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index df9888f797..222b4efc09 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -73,6 +73,8 @@ public enum AuthenticationFlow { public enum AuthView: Hashable { case passwordRecovery + /// A pushed email and password sign-up screen, reached with ``EmailAuthContentState/goToSignUp``. + case signUp case emailLink case updatePassword case mfaEnrollment diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift index 2d5762c3ca..b63798fec7 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift @@ -36,12 +36,19 @@ public struct DefaultAuthPickerDestination: View { self.screen = screen } + @Environment(AuthService.self) private var authService private let screen: AuthView public var body: some View { switch screen { case AuthView.passwordRecovery: PasswordRecoveryView() + case AuthView.signUp: + ScrollView { + EmailAuthScreen(flow: .signUp) { DefaultEmailAuthContent(state: $0) } + } + .safeAreaPadding() + .navigationTitle(authService.string.signUpWithEmailButtonLabel) case AuthView.emailLink: EmailLinkView() case AuthView.updatePassword: diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift index c14261e320..93af14201b 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift @@ -36,16 +36,21 @@ public struct EmailAuthContentState { /// their handlers. Does nothing unless ``isValid`` is `true` and ``isLoading`` is `false`. public let signUp: @MainActor () -> Void - /// Toggles ``flow`` between sign-in and sign-up, with animation. + /// Toggles ``flow`` between sign-in and sign-up, with animation. On a screen with a fixed + /// flow it navigates instead: sign-up pops back and sign-in pushes ``AuthView/signUp``. public let switchFlow: @MainActor () -> Void + /// Pushes ``AuthView/signUp``, for a sign-up screen of its own instead of toggling ``flow``. + public let goToSignUp: @MainActor () -> Void + /// Pushes ``AuthView/passwordRecovery``. public let goToPasswordRecovery: @MainActor () -> Void /// Starts email-link sign-in, the same as the stock email-link button. public let goToEmailLink: @MainActor () -> Void - /// Whether the form signs in or signs up. Shared with ``AuthService/authenticationFlow``. + /// Whether the form signs in or signs up. Shared with ``AuthService/authenticationFlow`` + /// unless the screen was created with a fixed flow. public let flow: AuthenticationFlow /// Whether the fields are valid for ``flow``: a valid email and a non-empty password to sign @@ -68,6 +73,7 @@ public struct EmailAuthContentState { signIn: @escaping @MainActor () -> Void, signUp: @escaping @MainActor () -> Void, switchFlow: @escaping @MainActor () -> Void, + goToSignUp: @escaping @MainActor () -> Void, goToPasswordRecovery: @escaping @MainActor () -> Void, goToEmailLink: @escaping @MainActor () -> Void, flow: AuthenticationFlow, @@ -80,6 +86,7 @@ public struct EmailAuthContentState { self.signIn = signIn self.signUp = signUp self.switchFlow = switchFlow + self.goToSignUp = goToSignUp self.goToPasswordRecovery = goToPasswordRecovery self.goToEmailLink = goToEmailLink self.flow = flow @@ -121,7 +128,12 @@ public struct EmailAuthContentState { /// ``` @MainActor public struct EmailAuthScreen: View { - public init(@ViewBuilder content: @escaping (EmailAuthContentState) -> Content) { + /// Creates the screen. Pass a `flow` to fix it to sign-in or sign-up, for example for a + /// custom ``AuthView/signUp`` destination; leave it `nil` to follow + /// ``AuthService/authenticationFlow`` and toggle it in place. + public init(flow: AuthenticationFlow? = nil, + @ViewBuilder content: @escaping (EmailAuthContentState) -> Content) { + fixedFlow = flow self.content = content } @@ -135,6 +147,7 @@ public struct EmailAuthScreen: View { @State private var isWorking = false @State private var errorMessage: String? + private let fixedFlow: AuthenticationFlow? private let content: (EmailAuthContentState) -> Content public var body: some View { @@ -145,9 +158,10 @@ public struct EmailAuthScreen: View { signIn: signIn, signUp: signUp, switchFlow: switchFlow, + goToSignUp: { authService.navigator.push(.signUp) }, goToPasswordRecovery: { authService.navigator.push(.passwordRecovery) }, goToEmailLink: { authService.selectEmailLink() }, - flow: authService.authenticationFlow, + flow: flow, isValid: isValid, isLoading: isLoading, errorMessage: errorMessage @@ -168,8 +182,12 @@ public struct EmailAuthScreen: View { } } + private var flow: AuthenticationFlow { + fixedFlow ?? authService.authenticationFlow + } + private var isValid: Bool { - return if authService.authenticationFlow == .signIn { + return if flow == .signIn { FormValidators.email.isValid(input: email) && !password.isEmpty } else { FormValidators.email.isValid(input: email) && @@ -183,6 +201,16 @@ public struct EmailAuthScreen: View { } private func switchFlow() { + switch fixedFlow { + case .signUp: + authService.navigator.pop() + return + case .signIn: + authService.navigator.push(.signUp) + return + case nil: + break + } withAnimation { authService.authenticationFlow = authService.authenticationFlow == .signIn ? .signUp : .signIn From 2065078ebd43c1882a10eabc11769a7b1e33138e Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:29:58 +0100 Subject: [PATCH 5/7] fix(auth): close the sign-up route after signing in and add SignUpView --- .../Sources/Services/AuthService.swift | 3 +- .../Views/DefaultAuthPickerDestination.swift | 7 +--- .../Sources/Views/EmailAuthScreen.swift | 20 ++++++--- .../Sources/Views/SignUpView.swift | 42 +++++++++++++++++++ 4 files changed, 60 insertions(+), 12 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignUpView.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 222b4efc09..ff1d2b6a4f 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -73,7 +73,8 @@ public enum AuthenticationFlow { public enum AuthView: Hashable { case passwordRecovery - /// A pushed email and password sign-up screen, reached with ``EmailAuthContentState/goToSignUp``. + /// A pushed email and password sign-up screen, ``SignUpView`` by default. Reached with + /// ``EmailAuthContentState/goToSignUp``. case signUp case emailLink case updatePassword diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift index b63798fec7..49921ec36a 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift @@ -36,7 +36,6 @@ public struct DefaultAuthPickerDestination: View { self.screen = screen } - @Environment(AuthService.self) private var authService private let screen: AuthView public var body: some View { @@ -44,11 +43,7 @@ public struct DefaultAuthPickerDestination: View { case AuthView.passwordRecovery: PasswordRecoveryView() case AuthView.signUp: - ScrollView { - EmailAuthScreen(flow: .signUp) { DefaultEmailAuthContent(state: $0) } - } - .safeAreaPadding() - .navigationTitle(authService.string.signUpWithEmailButtonLabel) + SignUpView() case AuthView.emailLink: EmailLinkView() case AuthView.updatePassword: diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift index 93af14201b..ac50dfb11d 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift @@ -128,9 +128,13 @@ public struct EmailAuthContentState { /// ``` @MainActor public struct EmailAuthScreen: View { - /// Creates the screen. Pass a `flow` to fix it to sign-in or sign-up, for example for a - /// custom ``AuthView/signUp`` destination; leave it `nil` to follow - /// ``AuthService/authenticationFlow`` and toggle it in place. + /// Creates the screen. Leave `flow` `nil` to follow ``AuthService/authenticationFlow`` and + /// toggle it in place, as the stock first screen does. + /// + /// Pass a `flow` to fix it and make ``EmailAuthContentState/switchFlow`` navigate instead: + /// `.signIn` pushes ``AuthView/signUp``, so a first screen fixed to `.signIn` gives the stock + /// "Sign up" link its own sign-up screen. Use `.signUp` only for an ``AuthView/signUp`` + /// destination, where `switchFlow` pops back to the screen that pushed it. public init(flow: AuthenticationFlow? = nil, @ViewBuilder content: @escaping (EmailAuthContentState) -> Content) { fixedFlow = flow @@ -203,7 +207,9 @@ public struct EmailAuthScreen: View { private func switchFlow() { switch fixedFlow { case .signUp: - authService.navigator.pop() + if authService.authView == .signUp { + authService.navigator.pop() + } return case .signIn: authService.navigator.push(.signUp) @@ -236,8 +242,12 @@ public struct EmailAuthScreen: View { Task { defer { isWorking = false } do { - if case let .mfaRequired(mfaInfo) = try await attempt() { + switch try await attempt() { + case let .mfaRequired(mfaInfo): mfaHandler?(mfaInfo) + case .signedIn: + // Close a pushed sign-up screen so the signed-in screen shows, like the other routes. + authService.navigator.clear() } } catch { if case let AuthServiceError.accountConflict(ctx) = error, diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignUpView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignUpView.swift new file mode 100644 index 0000000000..f04e740aff --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignUpView.swift @@ -0,0 +1,42 @@ +// 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 FirebaseCore +import SwiftUI + +/// The default ``AuthView/signUp`` screen: the stock email and password form, fixed to sign-up. +@MainActor +public struct SignUpView { + public init() {} + + @Environment(AuthService.self) private var authService +} + +extension SignUpView: View { + public var body: some View { + ScrollView { + EmailAuthScreen(flow: .signUp) { DefaultEmailAuthContent(state: $0) } + } + .safeAreaPadding() + .navigationTitle(authService.string.signUpWithEmailButtonLabel) + } +} + +#Preview { + FirebaseOptions.dummyConfigurationForPreview() + return NavigationStack { + SignUpView() + } + .environment(AuthService()) +} From 4ff8a8f5184d0ad41506d4bb79160c08a8cbf8ef Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:16:01 +0100 Subject: [PATCH 6/7] feat(auth): add a signIn route and goToEmailSignIn for a pushed email sign-in screen --- .../Sources/Services/AuthService.swift | 3 ++ .../Views/DefaultAuthPickerContent.swift | 17 +++++++- .../Views/DefaultAuthPickerDestination.swift | 2 + .../Sources/Views/EmailAuthScreen.swift | 5 ++- .../Sources/Views/SignInView.swift | 43 +++++++++++++++++++ 5 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignInView.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index ff1d2b6a4f..3051958cdc 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -73,6 +73,9 @@ public enum AuthenticationFlow { public enum AuthView: Hashable { case passwordRecovery + /// A pushed email and password sign-in screen, ``SignInView`` by default. Reached with + /// ``AuthMethodPickerState/goToEmailSignIn``. + case signIn /// A pushed email and password sign-up screen, ``SignUpView`` by default. Reached with /// ``EmailAuthContentState/goToSignUp``. case signUp diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift index 79f445e1ae..009af521bd 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -95,7 +95,9 @@ struct AuthMethodPickerStateReader: View { providers: authService.registeredProviders, select: selectProvider, isEmailLinkEnabled: authService.emailLinkSignInEnabled, - selectEmailLink: authService.selectEmailLink + selectEmailLink: authService.selectEmailLink, + isEmailSignInEnabled: authService.emailPasswordSignInEnabled, + goToEmailSignIn: { authService.navigator.push(.signIn) } )) } @@ -137,15 +139,26 @@ public struct AuthMethodPickerState { /// Starts email-link sign-in, the same as the stock email-link button. public let selectEmailLink: @MainActor () -> Void + /// Whether email and password sign-in is enabled on the ``AuthService``. + public let isEmailSignInEnabled: Bool + + /// Pushes ``AuthView/signIn``, an email and password sign-in screen of its own, for a first + /// screen that shows only provider buttons and a "Continue with email" button. + public let goToEmailSignIn: @MainActor () -> Void + /// Creates a state, for example to preview a custom layout with stub actions. public init(providers: [AuthProviderUI], select: @escaping @MainActor (AuthProviderUI) -> Void, isEmailLinkEnabled: Bool, - selectEmailLink: @escaping @MainActor () -> Void) { + selectEmailLink: @escaping @MainActor () -> Void, + isEmailSignInEnabled: Bool = false, + goToEmailSignIn: @escaping @MainActor () -> Void = {}) { self.providers = providers self.select = select self.isEmailLinkEnabled = isEmailLinkEnabled self.selectEmailLink = selectEmailLink + self.isEmailSignInEnabled = isEmailSignInEnabled + self.goToEmailSignIn = goToEmailSignIn } /// Hands a provider sign-in result to the same handlers the default provider buttons use: diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift index 49921ec36a..316b0c722c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift @@ -42,6 +42,8 @@ public struct DefaultAuthPickerDestination: View { switch screen { case AuthView.passwordRecovery: PasswordRecoveryView() + case AuthView.signIn: + SignInView() case AuthView.signUp: SignUpView() case AuthView.emailLink: diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift index ac50dfb11d..d07189d10a 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift @@ -133,8 +133,9 @@ public struct EmailAuthScreen: View { /// /// Pass a `flow` to fix it and make ``EmailAuthContentState/switchFlow`` navigate instead: /// `.signIn` pushes ``AuthView/signUp``, so a first screen fixed to `.signIn` gives the stock - /// "Sign up" link its own sign-up screen. Use `.signUp` only for an ``AuthView/signUp`` - /// destination, where `switchFlow` pops back to the screen that pushed it. + /// "Sign up" link its own sign-up screen, as ``SignInView`` does for ``AuthView/signIn``. Use + /// `.signUp` only for an ``AuthView/signUp`` destination, where `switchFlow` pops back to the + /// screen that pushed it. public init(flow: AuthenticationFlow? = nil, @ViewBuilder content: @escaping (EmailAuthContentState) -> Content) { fixedFlow = flow diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignInView.swift new file mode 100644 index 0000000000..dae247c7e5 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignInView.swift @@ -0,0 +1,43 @@ +// 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 FirebaseCore +import SwiftUI + +/// The default ``AuthView/signIn`` screen: the stock email and password form, fixed to sign-in. +/// Its "Sign up" link pushes ``AuthView/signUp``. +@MainActor +public struct SignInView { + public init() {} + + @Environment(AuthService.self) private var authService +} + +extension SignInView: View { + public var body: some View { + ScrollView { + EmailAuthScreen(flow: .signIn) { DefaultEmailAuthContent(state: $0) } + } + .safeAreaPadding() + .navigationTitle(authService.string.signInWithEmailButtonLabel) + } +} + +#Preview { + FirebaseOptions.dummyConfigurationForPreview() + return NavigationStack { + SignInView() + } + .environment(AuthService()) +} From d5cfb5c7f3f75716bb859e9e2e3cfd76ff2e13ac Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:41:38 +0100 Subject: [PATCH 7/7] fix(auth): reset pushed email routes after sign-in, sign-out, dismissal and legacy recovery --- .../Sources/Services/AuthService.swift | 13 ++++ .../Sources/Views/AuthPickerContent.swift | 10 ++- .../Views/DefaultAuthPickerContent.swift | 4 +- .../Sources/Views/EmailAuthScreen.swift | 10 +-- .../EmailRouteTests.swift | 72 +++++++++++++++++++ 5 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/EmailRouteTests.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 3051958cdc..13ef95d119 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -165,6 +165,12 @@ public class Navigator { public func clear() { routes.removeAll() } + + /// Removes pushed ``AuthView/signIn`` and ``AuthView/signUp`` screens, which have nothing left + /// to do once the user is signed in. + func removeEmailRoutes() { + routes.removeAll { $0 == .signIn || $0 == .signUp } + } } @MainActor @@ -298,6 +304,7 @@ public final class AuthService { isReauthenticating = false legacySignInRecovery = nil suggestedEmailAddress = nil + navigator.clear() updateAuthenticationState() } @@ -1249,7 +1256,13 @@ extension AuthService { switch option.id { case EmailAuthProviderID: + // An app whose first screen has no email form reached email sign-in through a pushed + // route, so return to that screen for the suggested address. + let usedEmailRoute = navigator.routes.contains { $0 == .signIn || $0 == .signUp } navigator.clear() + if usedEmailRoute { + navigator.push(.signIn) + } case emailLinkSignInMethod: navigator.clear() navigator.push(.emailLink) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift index 32852e8c47..12c46ac05c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift @@ -52,7 +52,8 @@ public struct AuthPickerContent< public var body: some View { @Bindable var authService = authService content() - .sheet(isPresented: $authService.isPresented) { + // Reopening the sheet starts from the first screen, not a half-finished pushed one. + .sheet(isPresented: $authService.isPresented, onDismiss: authService.navigator.clear) { @Bindable var navigator = authService.navigator NavigationStack(path: $navigator.routes) { root @@ -81,6 +82,13 @@ public struct AuthPickerContent< .environment(authService) } .interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled) + // Close pushed email screens however the user got signed in, including account + // conflicts the library resolves by signing in for them. + .onChange(of: authService.authenticationState) { _, state in + if state == .authenticated { + authService.navigator.removeEmailRoutes() + } + } // Apply account conflict handling at NavigationStack level .accountConflictHandler() // Apply MFA handling at NavigationStack level diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift index 009af521bd..c5f863f7d6 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -143,7 +143,9 @@ public struct AuthMethodPickerState { public let isEmailSignInEnabled: Bool /// Pushes ``AuthView/signIn``, an email and password sign-in screen of its own, for a first - /// screen that shows only provider buttons and a "Continue with email" button. + /// screen that shows only provider buttons and a "Continue with email" button. Use it from + /// `AuthPickerView`'s whole-screen `pickerContent`; ``DefaultAuthPickerContent`` already shows + /// the email form. public let goToEmailSignIn: @MainActor () -> Void /// Creates a state, for example to preview a custom layout with stub actions. diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift index d07189d10a..f4b74e297a 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift @@ -208,9 +208,7 @@ public struct EmailAuthScreen: View { private func switchFlow() { switch fixedFlow { case .signUp: - if authService.authView == .signUp { - authService.navigator.pop() - } + authService.navigator.pop() return case .signIn: authService.navigator.push(.signUp) @@ -243,12 +241,8 @@ public struct EmailAuthScreen: View { Task { defer { isWorking = false } do { - switch try await attempt() { - case let .mfaRequired(mfaInfo): + if case let .mfaRequired(mfaInfo) = try await attempt() { mfaHandler?(mfaInfo) - case .signedIn: - // Close a pushed sign-up screen so the signed-in screen shows, like the other routes. - authService.navigator.clear() } } catch { if case let AuthServiceError.accountConflict(ctx) = error, diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/EmailRouteTests.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/EmailRouteTests.swift new file mode 100644 index 0000000000..e88aafeeb5 --- /dev/null +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/EmailRouteTests.swift @@ -0,0 +1,72 @@ +// 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 +@testable import FirebaseAuthSwiftUI +import Testing + +@MainActor +struct EmailRouteTests { + private func makeService() -> AuthService { + configureFirebaseIfNeeded() + return AuthService() + } + + private let emailOption = LegacySignInOption(id: EmailAuthProviderID, displayName: "Email") + + @Test + func removeEmailRoutesKeepsOtherRoutes() { + let navigator = Navigator() + navigator.push(.signIn) + navigator.push(.signUp) + navigator.push(.passwordRecovery) + + navigator.removeEmailRoutes() + + #expect(navigator.routes == [.passwordRecovery]) + } + + @Test + func legacyEmailRecoveryReturnsToPushedSignIn() { + let service = makeService() + service.navigator.push(.signIn) + service.navigator.push(.signUp) + + service.startLegacyRecovery(option: emailOption, email: "user@example.com") + + #expect(service.navigator.routes == [.signIn]) + #expect(service.suggestedEmailAddress == "user@example.com") + } + + @Test + func legacyEmailRecoveryReturnsToFirstScreenWithoutEmailRoute() { + let service = makeService() + service.navigator.push(.enterPhoneNumber) + + service.startLegacyRecovery(option: emailOption, email: "user@example.com") + + #expect(service.navigator.routes.isEmpty) + } + + @Test + func signOutClearsNavigation() async throws { + let service = makeService() + service.navigator.push(.signIn) + service.navigator.push(.signUp) + + try await service.signOut() + + #expect(service.navigator.routes.isEmpty) + } +}