From c318494890747dcaf6f1df15a6b4e83822d51c93 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:12:58 +0100 Subject: [PATCH] feat(auth): add content slots to the reauthentication prompts --- .../Views/DefaultEmailLinkReauthContent.swift | 93 +++++++++ .../Views/DefaultEmailReauthContent.swift | 93 +++++++++ .../Views/DefaultPhoneReauthContent.swift | 133 +++++++++++++ .../Sources/Views/EmailLinkReauthScreen.swift | 147 ++++++++++++++ .../Sources/Views/EmailLinkReauthView.swift | 132 +------------ .../Sources/Views/EmailReauthScreen.swift | 136 +++++++++++++ .../Sources/Views/EmailReauthView.swift | 108 +---------- .../Sources/Views/PhoneReauthScreen.swift | 170 ++++++++++++++++ .../Sources/Views/PhoneReauthView.swift | 181 +----------------- .../Sources/Views/ReauthContent.swift | 104 ++++++++++ .../Views/ReauthenticationModifier.swift | 21 +- .../ReauthContentTests.swift | 181 ++++++++++++++++++ 12 files changed, 1072 insertions(+), 427 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkReauthContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailReauthContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPhoneReauthContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthScreen.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthScreen.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthScreen.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/ReauthContentTests.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkReauthContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkReauthContent.swift new file mode 100644 index 0000000000..7986df697b --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkReauthContent.swift @@ -0,0 +1,93 @@ +// 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 ``EmailLinkReauthScreen``. +@MainActor +public struct DefaultEmailLinkReauthContent: View { + public init(state: EmailLinkReauthContentState) { + self.state = state + } + + private let state: EmailLinkReauthContentState + + public var body: some View { + VStack(spacing: 24) { + if state.linkSent { + // "Check your email" state + VStack(spacing: 16) { + Image(systemName: "envelope.open.fill") + .font(.system(size: 60)) + .foregroundColor(.accentColor) + .padding(.top, 32) + + Text("Check Your Email") + .authFont(.title) + .fontWeight(.bold) + + Text("We've sent a verification link to:") + .authFont(.body) + .foregroundStyle(.secondary) + + Text(state.email) + .authFont(.body) + .fontWeight(.medium) + .padding(.horizontal) + + Text("Tap the link in the email to complete reauthentication.") + .authFont(.body) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal, 32) + .padding(.top, 8) + + Button(action: state.resendLink) { + if state.isLoading { + ProgressView() + .frame(height: 32) + } else { + Text("Resend Email") + .authCustomFont(.body) + .frame(height: 32) + } + } + .buttonStyle(.bordered) + .disabled(state.isLoading) + .padding(.top, 16) + } + } else { + // Loading/sending state + VStack(spacing: 16) { + ProgressView() + .padding(.top, 32) + Text("Sending verification email...") + .authCustomFont(.body) + .foregroundStyle(.secondary) + } + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle("Verify Your Identity") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", action: state.cancel) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailReauthContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailReauthContent.swift new file mode 100644 index 0000000000..a35fc00fb7 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailReauthContent.swift @@ -0,0 +1,93 @@ +// 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 ``EmailReauthScreen``. +@MainActor +public struct DefaultEmailReauthContent: View { + public init(state: EmailReauthContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: EmailReauthContentState + + public var body: some View { + VStack(spacing: 24) { + // Header + VStack(spacing: 12) { + Image(systemName: "lock.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.blue) + + Text(authService.string.confirmPasswordTitle) + .authFont(.title) + .fontWeight(.bold) + + Text(authService.string.forSecurityEnterPasswordMessage) + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding() + + VStack(spacing: 20) { + Text(authService.string.emailPrefix(email: state.email)) + .authFont(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 8) + + AuthTextField( + text: state.password, + label: authService.string.passwordFieldLabel, + prompt: authService.string.passwordInputLabel, + contentType: .password, + isSecureTextField: true, + onSubmit: { _ in + state.verify() + }, + leading: { + Image(systemName: "lock") + } + ) + .submitLabel(.done) + .accessibilityIdentifier("email-reauth-password-field") + + Button(action: state.verify) { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.confirmButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .authCTAButtonStyle() + .disabled(!state.isValid || state.isLoading) + .accessibilityIdentifier("confirm-password-button") + + Button(authService.string.cancelButtonLabel, action: state.cancel) + } + .padding(.horizontal) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPhoneReauthContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPhoneReauthContent.swift new file mode 100644 index 0000000000..137e5a7d2b --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPhoneReauthContent.swift @@ -0,0 +1,133 @@ +// 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 ``PhoneReauthScreen``. +@MainActor +public struct DefaultPhoneReauthContent: View { + public init(state: PhoneReauthContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: PhoneReauthContentState + + public var body: some View { + VStack(spacing: 24) { + // Header + VStack(spacing: 12) { + Image(systemName: "phone.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.blue) + + Text(authService.string.verifyPhoneNumberTitle) + .authFont(.title) + .fontWeight(.bold) + + Text(authService.string.forSecurityVerifyPhoneMessage) + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding() + + if !state.codeSent { + // Initial state - sending SMS + VStack(spacing: 20) { + Text(authService.string.sendVerificationCodeToPhonePrefix) + .authFont(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(state.phoneNumber) + .authFont(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 8) + + Button(action: state.sendCode) { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.sendVerificationCodeButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .authCTAButtonStyle() + .disabled(state.isLoading) + .accessibilityIdentifier("send-verification-code-button") + Button(authService.string.cancelButtonLabel, action: state.cancel) + } + .padding(.horizontal) + } else { + // Enter verification code + VStack(spacing: 20) { + Text(authService.string.enterSixDigitCodeSentToPrefix) + .authFont(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(state.phoneNumber) + .authFont(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 8) + + VerificationCodeInputField( + code: state.verificationCode, + validations: [ + FormValidators.verificationCode, + ], + maintainsValidationMessage: true + ) + .accessibilityIdentifier("verification-code-field") + + Button(action: state.verify) { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.verifyButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .authCTAButtonStyle() + .disabled(!state.isValid || state.isLoading) + .accessibilityIdentifier("verify-button") + + Button(action: state.sendCode) { + Text("Resend Code") + .frame(height: 32) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(state.isLoading) + .accessibilityIdentifier("resend-code-button") + + Button(authService.string.cancelButtonLabel, action: state.cancel) + } + .padding(.horizontal) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthScreen.swift new file mode 100644 index 0000000000..bc0bbee939 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthScreen.swift @@ -0,0 +1,147 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +import SwiftUI + +/// The state and actions of the email link reauthentication prompt, passed to +/// ``EmailLinkReauthScreen``'s content. +/// +/// The screen sends the first link when it appears and completes reauthentication when the app +/// opens the link. +public struct EmailLinkReauthContentState { + /// Sends a new link to ``email``. Does nothing while ``isLoading`` is `true`. + public let resendLink: @MainActor () -> Void + + /// Cancels reauthentication and dismisses the prompt. + public let cancel: @MainActor () -> Void + + /// The email address of the signed-in user. + public let email: String + + /// Whether a link was sent, so the content should ask the user to open it. + public let linkSent: Bool + + /// Whether a link is being sent. + public let isLoading: Bool + + /// The localized message of the last failed send or link sign-in. It clears when the next + /// attempt starts. The prompt'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(resendLink: @escaping @MainActor () -> Void, + cancel: @escaping @MainActor () -> Void, + email: String, + linkSent: Bool, + isLoading: Bool, + errorMessage: String?) { + self.resendLink = resendLink + self.cancel = cancel + self.email = email + self.linkSent = linkSent + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The email link reauthentication prompt, with its content drawn by you. +/// +/// The screen sends the link, reauthenticates when the app opens it, resolves a second factor, +/// and reports errors. Your content only draws the UI from the ``EmailLinkReauthContentState`` +/// it receives. Apps usually supply content with `reauthContent(_:)` rather than creating this +/// screen. +@MainActor +public struct EmailLinkReauthScreen: View { + public init(email: String, + coordinator: ReauthenticationCoordinator, + @ViewBuilder content: @escaping (EmailLinkReauthContentState) -> Content) { + self.email = email + self.coordinator = coordinator + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var linkSent = false + @State private var isLoading = false + @State private var errorMessage: String? + @State private var error: AlertError? + @State private var mfaRequired: MFARequired? + + private let email: String + private let coordinator: ReauthenticationCoordinator + private let content: (EmailLinkReauthContentState) -> Content + + public var body: some View { + NavigationStack { + content(EmailLinkReauthContentState( + resendLink: { Task { await sendLink() } }, + cancel: { coordinator.reauthCancelled() }, + email: email, + linkSent: linkSent, + isLoading: isLoading, + errorMessage: errorMessage + )) + .onOpenURL { url in + handleReauthURL(url) + } + .task { + await sendLink() + } + .mfaReauthResolution($mfaRequired, coordinator: coordinator) + } + .errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel) + } + + private func sendLink() async { + guard !isLoading else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + try await authService.sendEmailSignInLink(email: email, isReauth: true) + linkSent = true + } catch { + report(error) + } + } + + private func handleReauthURL(_ url: URL) { + errorMessage = nil + Task { + do { + try await authService.handleSignInLink(url: url) + coordinator.reauthCompleted() + } catch let AuthServiceError.multiFactorReauthenticationRequired(mfaRequired) { + self.mfaRequired = mfaRequired + } catch { + report(error) + } + } + } + + private func report(_ error: Error) { + errorMessage = authService.string.localizedErrorMessage(for: error) + if let reportError { + reportError(error) + } else { + self.error = AlertError( + title: "Error", + message: error.localizedDescription, + underlyingError: error + ) + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift index a4987fd06c..7a5a71dd92 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift @@ -12,148 +12,20 @@ // 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 EmailLinkReauthView { - @Environment(AuthService.self) private var authService - @Environment(\.reportError) private var reportError - let email: String let coordinator: ReauthenticationCoordinator - - @State private var emailSent = false - @State private var isLoading = false - @State private var error: AlertError? - @State private var mfaRequired: MFARequired? - - private func sendEmailLink() async { - isLoading = true - do { - try await authService.sendEmailSignInLink(email: email, isReauth: true) - emailSent = true - isLoading = false - } catch { - if let reportError = reportError { - reportError(error) - } else { - self.error = AlertError( - title: "Error", - message: error.localizedDescription, - underlyingError: error - ) - } - isLoading = false - } - } - - private func handleReauthURL(_ url: URL) { - Task { @MainActor in - do { - try await authService.handleSignInLink(url: url) - coordinator.reauthCompleted() - } catch let AuthServiceError.multiFactorReauthenticationRequired(mfaRequired) { - self.mfaRequired = mfaRequired - } catch { - if let reportError = reportError { - reportError(error) - } else { - self.error = AlertError( - title: "Error", - message: error.localizedDescription, - underlyingError: error - ) - } - } - } - } } extension EmailLinkReauthView: View { public var body: some View { - NavigationStack { - VStack(spacing: 24) { - if emailSent { - // "Check your email" state - VStack(spacing: 16) { - Image(systemName: "envelope.open.fill") - .font(.system(size: 60)) - .foregroundColor(.accentColor) - .padding(.top, 32) - - Text("Check Your Email") - .authFont(.title) - .fontWeight(.bold) - - Text("We've sent a verification link to:") - .authFont(.body) - .foregroundStyle(.secondary) - - Text(email) - .authFont(.body) - .fontWeight(.medium) - .padding(.horizontal) - - Text("Tap the link in the email to complete reauthentication.") - .authFont(.body) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .padding(.horizontal, 32) - .padding(.top, 8) - - Button { - Task { - await sendEmailLink() - } - } label: { - if isLoading { - ProgressView() - .frame(height: 32) - } else { - Text("Resend Email") - .authCustomFont(.body) - .frame(height: 32) - } - } - .buttonStyle(.bordered) - .disabled(isLoading) - .padding(.top, 16) - } - } else { - // Loading/sending state - VStack(spacing: 16) { - ProgressView() - .padding(.top, 32) - Text("Sending verification email...") - .authCustomFont(.body) - .foregroundStyle(.secondary) - } - } - - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .navigationTitle("Verify Your Identity") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - coordinator.reauthCancelled() - } - } - } - .onOpenURL { url in - handleReauthURL(url) - } - .task { - await sendEmailLink() - } - .mfaReauthResolution($mfaRequired, coordinator: coordinator) + EmailLinkReauthScreen(email: email, coordinator: coordinator) { + DefaultEmailLinkReauthContent(state: $0) } - .errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthScreen.swift new file mode 100644 index 0000000000..b107e8cbcc --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthScreen.swift @@ -0,0 +1,136 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +import SwiftUI + +/// The state and actions of the password reauthentication prompt, passed to +/// ``EmailReauthScreen``'s content. +public struct EmailReauthContentState { + /// The password the user typed. + public let password: Binding + + /// Reauthenticates with ``email`` and ``password``, pushing second-factor resolution when the + /// user has MFA. Does nothing unless ``isValid`` is `true` and ``isLoading`` is `false`. + public let verify: @MainActor () -> Void + + /// Cancels reauthentication and dismisses the prompt. + public let cancel: @MainActor () -> Void + + /// The email address of the signed-in user. + public let email: String + + /// Whether ``password`` is not empty. + public let isValid: Bool + + /// Whether a reauthentication attempt is in flight. + public let isLoading: Bool + + /// The localized message of the last failed attempt. It clears when the password changes or + /// the next attempt starts. The prompt'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(password: Binding, + verify: @escaping @MainActor () -> Void, + cancel: @escaping @MainActor () -> Void, + email: String, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.password = password + self.verify = verify + self.cancel = cancel + self.email = email + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The password reauthentication prompt, with its content drawn by you. +/// +/// The screen reauthenticates, resolves a second factor, and reports errors. Your content only +/// draws the UI from the ``EmailReauthContentState`` it receives. Apps usually supply content +/// with `reauthContent(_:)` rather than creating this screen. +@MainActor +public struct EmailReauthScreen: View { + public init(email: String, + coordinator: ReauthenticationCoordinator, + @ViewBuilder content: @escaping (EmailReauthContentState) -> Content) { + self.email = email + self.coordinator = coordinator + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var password = "" + @State private var isLoading = false + @State private var errorMessage: String? + @State private var error: AlertError? + @State private var mfaRequired: MFARequired? + + private let email: String + private let coordinator: ReauthenticationCoordinator + private let content: (EmailReauthContentState) -> Content + + public var body: some View { + NavigationStack { + content(EmailReauthContentState( + password: $password, + verify: verify, + cancel: { coordinator.reauthCancelled() }, + email: email, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .mfaReauthResolution($mfaRequired, coordinator: coordinator) + } + .onChange(of: password) { errorMessage = nil } + .errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel) + } + + private var isValid: Bool { + !password.isEmpty + } + + private func verify() { + guard isValid, !isLoading else { return } + isLoading = true + errorMessage = nil + Task { + defer { isLoading = false } + do { + let credential = EmailAuthProvider.credential(withEmail: email, password: password) + try await authService.reauthenticate(with: credential) + coordinator.reauthCompleted() + } catch let AuthServiceError.multiFactorReauthenticationRequired(mfaRequired) { + self.mfaRequired = mfaRequired + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + if let reportError { + reportError(error) + } else { + self.error = AlertError( + title: "Error", + message: error.localizedDescription, + underlyingError: error + ) + } + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift index 42b6af1f3c..a789009cd2 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift @@ -12,124 +12,20 @@ // 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 EmailReauthView { - @Environment(AuthService.self) private var authService - @Environment(\.reportError) private var reportError - let email: String let coordinator: ReauthenticationCoordinator - - @State private var password = "" - @State private var isLoading = false - @State private var error: AlertError? - @State private var mfaRequired: MFARequired? - - private func verifyPassword() { - guard !password.isEmpty else { return } - - Task { @MainActor in - isLoading = true - do { - let credential = EmailAuthProvider.credential(withEmail: email, password: password) - try await authService.reauthenticate(with: credential) - coordinator.reauthCompleted() - isLoading = false - } catch let AuthServiceError.multiFactorReauthenticationRequired(mfaRequired) { - self.mfaRequired = mfaRequired - isLoading = false - } catch { - if let reportError = reportError { - reportError(error) - } else { - self.error = AlertError( - title: "Error", - message: error.localizedDescription, - underlyingError: error - ) - } - isLoading = false - } - } - } } extension EmailReauthView: View { public var body: some View { - NavigationStack { - VStack(spacing: 24) { - // Header - VStack(spacing: 12) { - Image(systemName: "lock.circle.fill") - .font(.system(size: 60)) - .foregroundColor(.blue) - - Text(authService.string.confirmPasswordTitle) - .authFont(.title) - .fontWeight(.bold) - - Text(authService.string.forSecurityEnterPasswordMessage) - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .padding() - - VStack(spacing: 20) { - Text(authService.string.emailPrefix(email: email)) - .authFont(.caption) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.bottom, 8) - - AuthTextField( - text: $password, - label: authService.string.passwordFieldLabel, - prompt: authService.string.passwordInputLabel, - contentType: .password, - isSecureTextField: true, - onSubmit: { _ in - verifyPassword() - }, - leading: { - Image(systemName: "lock") - } - ) - .submitLabel(.done) - .accessibilityIdentifier("email-reauth-password-field") - - Button(action: verifyPassword) { - if isLoading { - ProgressView() - .frame(height: 32) - .frame(maxWidth: .infinity) - } else { - Text(authService.string.confirmButtonLabel) - .frame(height: 32) - .frame(maxWidth: .infinity) - } - } - .authCTAButtonStyle() - .disabled(password.isEmpty || isLoading) - .accessibilityIdentifier("confirm-password-button") - - Button(authService.string.cancelButtonLabel) { - coordinator.reauthCancelled() - } - } - .padding(.horizontal) - - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .navigationBarTitleDisplayMode(.inline) - .mfaReauthResolution($mfaRequired, coordinator: coordinator) + EmailReauthScreen(email: email, coordinator: coordinator) { + DefaultEmailReauthContent(state: $0) } - .errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthScreen.swift new file mode 100644 index 0000000000..dcf6a0642a --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthScreen.swift @@ -0,0 +1,170 @@ +// 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. + +@preconcurrency import FirebaseAuth +import SwiftUI + +/// The state and actions of the phone reauthentication prompt, passed to +/// ``PhoneReauthScreen``'s content. +public struct PhoneReauthContentState { + /// The code the user typed. + public let verificationCode: Binding + + /// Sends a verification code to ``phoneNumber``, or a new one once ``codeSent`` is `true`. + /// Does nothing while ``isLoading`` is `true`. + public let sendCode: @MainActor () -> Void + + /// Reauthenticates with ``verificationCode``, pushing second-factor resolution when the user + /// has MFA. Does nothing unless ``codeSent`` and ``isValid`` are `true` and ``isLoading`` is + /// `false`. + public let verify: @MainActor () -> Void + + /// Cancels reauthentication and dismisses the prompt. + public let cancel: @MainActor () -> Void + + /// The phone number of the signed-in user. + public let phoneNumber: String + + /// Whether a code was sent, so the content should ask for it. + public let codeSent: Bool + + /// Whether ``verificationCode`` has 6 characters. + public let isValid: Bool + + /// Whether a send or verify is in flight. + public let isLoading: Bool + + /// The localized message of the last failed send or verify. It clears when the code changes + /// or the next attempt starts. The prompt'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(verificationCode: Binding, + sendCode: @escaping @MainActor () -> Void, + verify: @escaping @MainActor () -> Void, + cancel: @escaping @MainActor () -> Void, + phoneNumber: String, + codeSent: Bool, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.verificationCode = verificationCode + self.sendCode = sendCode + self.verify = verify + self.cancel = cancel + self.phoneNumber = phoneNumber + self.codeSent = codeSent + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The phone reauthentication prompt, with its content drawn by you. +/// +/// The screen sends the code, reauthenticates, resolves a second factor, and reports errors. +/// Your content only draws the UI from the ``PhoneReauthContentState`` it receives. Apps usually +/// supply content with `reauthContent(_:)` rather than creating this screen. +@MainActor +public struct PhoneReauthScreen: View { + public init(phoneNumber: String, + coordinator: ReauthenticationCoordinator, + @ViewBuilder content: @escaping (PhoneReauthContentState) -> Content) { + self.phoneNumber = phoneNumber + self.coordinator = coordinator + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var verificationID: String? + @State private var verificationCode = "" + @State private var isLoading = false + @State private var errorMessage: String? + @State private var error: AlertError? + @State private var mfaRequired: MFARequired? + + private let phoneNumber: String + private let coordinator: ReauthenticationCoordinator + private let content: (PhoneReauthContentState) -> Content + + public var body: some View { + NavigationStack { + content(PhoneReauthContentState( + verificationCode: $verificationCode, + sendCode: sendCode, + verify: verify, + cancel: { coordinator.reauthCancelled() }, + phoneNumber: phoneNumber, + codeSent: verificationID != nil, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .mfaReauthResolution($mfaRequired, coordinator: coordinator) + } + .onChange(of: verificationCode) { errorMessage = nil } + .errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel) + } + + private var isValid: Bool { + verificationCode.count == 6 + } + + private func sendCode() { + guard !isLoading else { return } + isLoading = true + errorMessage = nil + Task { + defer { isLoading = false } + do { + verificationID = try await authService.verifyPhoneNumber(phoneNumber: phoneNumber) + } catch { + report(error) + } + } + } + + private func verify() { + guard let verificationID, isValid, !isLoading else { return } + isLoading = true + errorMessage = nil + Task { + defer { isLoading = false } + do { + let credential = PhoneAuthProvider.provider() + .credential(withVerificationID: verificationID, verificationCode: verificationCode) + try await authService.reauthenticate(with: credential) + coordinator.reauthCompleted() + } catch let AuthServiceError.multiFactorReauthenticationRequired(mfaRequired) { + self.mfaRequired = mfaRequired + } catch { + report(error) + } + } + } + + private func report(_ error: Error) { + errorMessage = authService.string.localizedErrorMessage(for: error) + if let reportError { + reportError(error) + } else { + self.error = AlertError( + title: "Error", + message: error.localizedDescription, + underlyingError: error + ) + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift index de8166f3c7..d8941b5b1d 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift @@ -12,197 +12,20 @@ // See the License for the specific language governing permissions and // limitations under the License. -@preconcurrency import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor public struct PhoneReauthView { - @Environment(AuthService.self) private var authService - @Environment(\.reportError) private var reportError - let phoneNumber: String let coordinator: ReauthenticationCoordinator - - @State private var verificationID: String? - @State private var verificationCode = "" - @State private var isLoading = false - @State private var error: AlertError? - @State private var mfaRequired: MFARequired? - - private func sendSMS() { - Task { @MainActor in - isLoading = true - do { - let vid = try await authService.verifyPhoneNumber(phoneNumber: phoneNumber) - verificationID = vid - isLoading = false - } catch { - if let reportError = reportError { - reportError(error) - } else { - self.error = AlertError( - title: "Error", - message: error.localizedDescription, - underlyingError: error - ) - } - isLoading = false - } - } - } - - private func verifyCode() { - guard let verificationID = verificationID else { return } - - Task { @MainActor in - isLoading = true - do { - let credential = PhoneAuthProvider.provider() - .credential(withVerificationID: verificationID, verificationCode: verificationCode) - - try await authService.reauthenticate(with: credential) - coordinator.reauthCompleted() - isLoading = false - } catch let AuthServiceError.multiFactorReauthenticationRequired(mfaRequired) { - self.mfaRequired = mfaRequired - isLoading = false - } catch { - if let reportError = reportError { - reportError(error) - } else { - self.error = AlertError( - title: "Error", - message: error.localizedDescription, - underlyingError: error - ) - } - isLoading = false - } - } - } } extension PhoneReauthView: View { public var body: some View { - NavigationStack { - VStack(spacing: 24) { - // Header - VStack(spacing: 12) { - Image(systemName: "phone.circle.fill") - .font(.system(size: 60)) - .foregroundColor(.blue) - - Text(authService.string.verifyPhoneNumberTitle) - .authFont(.title) - .fontWeight(.bold) - - Text(authService.string.forSecurityVerifyPhoneMessage) - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .padding() - - if verificationID == nil { - // Initial state - sending SMS - VStack(spacing: 20) { - Text(authService.string.sendVerificationCodeToPhonePrefix) - .authFont(.subheadline) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - - Text(phoneNumber) - .authFont(.headline) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.bottom, 8) - - Button(action: { - sendSMS() - }) { - if isLoading { - ProgressView() - .frame(height: 32) - .frame(maxWidth: .infinity) - } else { - Text(authService.string.sendVerificationCodeButtonLabel) - .frame(height: 32) - .frame(maxWidth: .infinity) - } - } - .authCTAButtonStyle() - .disabled(isLoading) - .accessibilityIdentifier("send-verification-code-button") - Button(authService.string.cancelButtonLabel) { - coordinator.reauthCancelled() - } - } - .padding(.horizontal) - } else { - // Enter verification code - VStack(spacing: 20) { - Text(authService.string.enterSixDigitCodeSentToPrefix) - .authFont(.subheadline) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - - Text(phoneNumber) - .authFont(.caption) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.bottom, 8) - - VerificationCodeInputField( - code: $verificationCode, - validations: [ - FormValidators.verificationCode, - ], - maintainsValidationMessage: true - ) - .accessibilityIdentifier("verification-code-field") - - Button(action: { - verifyCode() - }) { - if isLoading { - ProgressView() - .frame(height: 32) - .frame(maxWidth: .infinity) - } else { - Text(authService.string.verifyButtonLabel) - .frame(height: 32) - .frame(maxWidth: .infinity) - } - } - .authCTAButtonStyle() - .disabled(verificationCode.count != 6 || isLoading) - .accessibilityIdentifier("verify-button") - - Button(action: { - sendSMS() - }) { - Text("Resend Code") - .frame(height: 32) - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .disabled(isLoading) - .accessibilityIdentifier("resend-code-button") - - Button(authService.string.cancelButtonLabel) { - coordinator.reauthCancelled() - } - } - .padding(.horizontal) - } - - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .navigationBarTitleDisplayMode(.inline) - .mfaReauthResolution($mfaRequired, coordinator: coordinator) + PhoneReauthScreen(phoneNumber: phoneNumber, coordinator: coordinator) { + DefaultPhoneReauthContent(state: $0) } - .errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthContent.swift new file mode 100644 index 0000000000..2002045cb0 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthContent.swift @@ -0,0 +1,104 @@ +// 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 + +/// A reauthentication prompt and its state, passed to the content given to +/// ``SwiftUICore/View/reauthContent(_:)``. +public enum ReauthPrompt { + /// The user signed in with email and password. + case password(EmailReauthContentState) + /// The user signed in with a phone number. + case phone(PhoneReauthContentState) + /// The user signed in with an email link. + case emailLink(EmailLinkReauthContentState) +} + +/// The stock content for a reauthentication prompt, for the cases your +/// ``SwiftUICore/View/reauthContent(_:)`` content does not replace. +@MainActor +public struct DefaultReauthContent: View { + public init(prompt: ReauthPrompt) { + self.prompt = prompt + } + + private let prompt: ReauthPrompt + + public var body: some View { + switch prompt { + case let .password(state): + DefaultEmailReauthContent(state: state) + case let .phone(state): + DefaultPhoneReauthContent(state: state) + case let .emailLink(state): + DefaultEmailLinkReauthContent(state: state) + } + } +} + +private struct ReauthContentKey: @preconcurrency EnvironmentKey { + @MainActor static let defaultValue: (@MainActor (ReauthPrompt) -> AnyView)? = nil +} + +extension EnvironmentValues { + var reauthContent: (@MainActor (ReauthPrompt) -> AnyView)? { + get { self[ReauthContentKey.self] } + set { self[ReauthContentKey.self] = newValue } + } +} + +/// Draws the content of a reauthentication prompt, using the app's content when one was given. +struct ReauthPromptContent: View { + @Environment(\.reauthContent) private var reauthContent + let prompt: ReauthPrompt + + var body: some View { + if let reauthContent { + reauthContent(prompt) + } else { + DefaultReauthContent(prompt: prompt) + } + } +} + +public extension View { + /// Replaces the content of the password, phone, and email link reauthentication prompts shown + /// inside this view. + /// + /// Reauthentication is asked for when a sensitive action, such as deleting the account or + /// changing the password, needs a recent sign-in. The prompts stay sheets that verify the + /// user, resolve a second factor, and report errors. Your content only draws the UI from the + /// state in the ``ReauthPrompt`` it receives. Return ``DefaultReauthContent`` for prompts you + /// do not replace. + /// + /// Example: + /// ```swift + /// AuthPickerView { authenticatedApp } + /// .reauthContent { prompt in + /// switch prompt { + /// case let .password(state): + /// SecureField("Password", text: state.password) + /// Button("Confirm", action: state.verify) + /// .disabled(!state.isValid || state.isLoading) + /// Button("Cancel", action: state.cancel) + /// default: + /// DefaultReauthContent(prompt: prompt) + /// } + /// } + /// ``` + func reauthContent(@ViewBuilder _ content: @escaping @MainActor (ReauthPrompt) + -> Content) -> some View { + environment(\.reauthContent) { AnyView(content($0)) } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift index 07ced40de4..b45e102de3 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift @@ -60,10 +60,9 @@ struct ReauthenticationModifier: ViewModifier { coordinator.reauthCancelled() }) { if case let .phone(context) = coordinator.reauthContext { - PhoneReauthView( - phoneNumber: context.phoneNumber, - coordinator: coordinator - ) + PhoneReauthScreen(phoneNumber: context.phoneNumber, coordinator: coordinator) { + ReauthPromptContent(prompt: .phone($0)) + } // Show errors inside the sheet; a parent's reportError alert would sit behind it .environment(\.reportError, nil) } @@ -74,10 +73,9 @@ struct ReauthenticationModifier: ViewModifier { coordinator.reauthCancelled() }) { if case let .email(context) = coordinator.reauthContext { - EmailReauthView( - email: context.email, - coordinator: coordinator - ) + EmailReauthScreen(email: context.email, coordinator: coordinator) { + ReauthPromptContent(prompt: .password($0)) + } // Show errors inside the sheet; a parent's reportError alert would sit behind it .environment(\.reportError, nil) } @@ -104,10 +102,9 @@ struct ReauthenticationModifier: ViewModifier { coordinator.reauthCancelled() }) { if case let .emailLink(context) = coordinator.reauthContext { - EmailLinkReauthView( - email: context.email, - coordinator: coordinator - ) + EmailLinkReauthScreen(email: context.email, coordinator: coordinator) { + ReauthPromptContent(prompt: .emailLink($0)) + } // Show errors inside the sheet; a parent's reportError alert would sit behind it .environment(\.reportError, nil) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/ReauthContentTests.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/ReauthContentTests.swift new file mode 100644 index 0000000000..ccf3e63f55 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/ReauthContentTests.swift @@ -0,0 +1,181 @@ +// 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. + +@testable import FirebaseAuthSwiftUI +import FirebaseCore +import SwiftUI +import Testing + +/// Renders `view` in a window so its body and environment are evaluated, and keeps it on screen +/// until the returned window is released. +@MainActor +private func host(_ view: some View) -> UIWindow { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = UIHostingController(rootView: view) + window.makeKeyAndVisible() + window.layoutIfNeeded() + return window +} + +/// Lets SwiftUI apply pending state changes and re-render. +@MainActor +private func settle() { + RunLoop.main.run(until: Date().addingTimeInterval(0.1)) +} + +@MainActor +private final class Recorder { + var value: Value? + + /// Stores `value` while content is drawn, standing in for the app's content. + func capture(_ value: Value) -> some View { + self.value = value + return EmptyView() + } +} + +@Suite("Reauth screens") +@MainActor +struct ReauthScreenTests { + init() { + FirebaseOptions.dummyConfigurationForPreview() + } + + @Test("Password screen is valid once a password is entered") + func passwordValidity() { + let state = Recorder() + let window = host( + EmailReauthScreen(email: "test@example.com", coordinator: ReauthenticationCoordinator()) { + state.capture($0) + } + .environment(AuthService()) + ) + defer { window.isHidden = true } + + #expect(state.value?.email == "test@example.com") + #expect(state.value?.isValid == false) + #expect(state.value?.isLoading == false) + #expect(state.value?.errorMessage == nil) + + state.value?.password.wrappedValue = "secret" + settle() + + #expect(state.value?.isValid == true) + } + + @Test("Phone screen asks for a code only after one is sent, and needs 6 characters") + func phoneValidity() { + let state = Recorder() + let window = host( + PhoneReauthScreen(phoneNumber: "+15555550100", coordinator: ReauthenticationCoordinator()) { + state.capture($0) + } + .environment(AuthService()) + ) + defer { window.isHidden = true } + + #expect(state.value?.phoneNumber == "+15555550100") + #expect(state.value?.codeSent == false) + #expect(state.value?.isValid == false) + + state.value?.verificationCode.wrappedValue = "12345" + settle() + #expect(state.value?.isValid == false) + + state.value?.verificationCode.wrappedValue = "123456" + settle() + #expect(state.value?.isValid == true) + } + + @Test("Cancel ends the pending reauthentication request") + func cancelEndsRequest() async { + let coordinator = ReauthenticationCoordinator() + let state = Recorder() + let window = host( + EmailReauthScreen(email: "test@example.com", coordinator: coordinator) { + state.capture($0) + } + .environment(AuthService()) + ) + defer { window.isHidden = true } + + let request = Task { + try await coordinator + .requestReauth(context: .email(EmailReauthContext(email: "test@example.com"))) + } + await Task.yield() + #expect(coordinator.showingEmailPasswordPrompt) + + state.value?.cancel() + + await #expect(throws: AuthServiceError.self) { try await request.value } + #expect(!coordinator.showingEmailPasswordPrompt) + } +} + +@Suite("Reauth content slot") +@MainActor +struct ReauthContentSlotTests { + private let prompt = ReauthPrompt.emailLink(EmailLinkReauthContentState( + resendLink: {}, + cancel: {}, + email: "test@example.com", + linkSent: true, + isLoading: false, + errorMessage: nil + )) + + @Test("No reauth content is set by default") + func defaultIsNil() { + #expect(EnvironmentValues().reauthContent == nil) + } + + @Test("Prompts inside reauthContent are drawn by the app's content") + func usesAppContent() { + let received = Recorder() + let window = host( + ReauthPromptContent(prompt: prompt) + .reauthContent { prompt in + received.capture(prompt) + } + ) + defer { window.isHidden = true } + + guard case let .emailLink(state) = received.value else { + Issue.record("Expected the email link prompt, got \(String(describing: received.value))") + return + } + #expect(state.email == "test@example.com") + #expect(state.linkSent) + } + + @Test("The nearest reauthContent wins") + func innermostContentWins() { + let outer = Recorder() + let inner = Recorder() + let window = host( + ReauthPromptContent(prompt: prompt) + .reauthContent { prompt in + inner.capture(prompt) + } + .reauthContent { prompt in + outer.capture(prompt) + } + ) + defer { window.isHidden = true } + + #expect(inner.value != nil) + #expect(outer.value == nil) + } +}