From 41d3a07eceafa56f7404bcc069443eafd9243e7d Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:15:50 +0100 Subject: [PATCH 1/2] feat(auth): add content slots to MFA resolution and management screens Add public MFAResolutionScreen and MFAManagementScreen, which own the screen state and actions and pass MFAResolutionContentState / MFAManagementContentState to consumer content. DefaultMFAResolutionContent and DefaultMFAManagementContent hold the stock markup, and MFAResolutionView and MFAManagementView keep their public signatures by rendering them through the screens. MFAManagementScreen keeps the reauthentication modifier outside the content, so the reauth sheet presents for custom content too. --- .../Views/DefaultMFAManagementContent.swift | 166 +++++++++ .../Views/DefaultMFAResolutionContent.swift | 284 +++++++++++++++ .../Sources/Views/MFAManagementScreen.swift | 142 ++++++++ .../Sources/Views/MFAManagementView.swift | 177 +--------- .../Sources/Views/MFAResolutionScreen.swift | 211 ++++++++++++ .../Sources/Views/MFAResolutionView.swift | 326 +----------------- 6 files changed, 805 insertions(+), 501 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAManagementContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAResolutionContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementScreen.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionScreen.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAManagementContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAManagementContent.swift new file mode 100644 index 0000000000..0b749689d4 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAManagementContent.swift @@ -0,0 +1,166 @@ +// 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 stock content of ``MFAManagementScreen``. +@MainActor +public struct DefaultMFAManagementContent: View { + public init(state: MFAManagementContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: MFAManagementContentState + + public var body: some View { + VStack(spacing: 20) { + // Title section + VStack { + Text("Two-Factor Authentication") + .authFont(.largeTitle) + .fontWeight(.bold) + .multilineTextAlignment(.center) + + Text("Manage your authentication methods") + .authFont(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal) + + if state.enrolledFactors.isEmpty { + // No factors enrolled + VStack(spacing: 16) { + Image(systemName: "shield.slash") + .font(.system(size: 48)) + .foregroundColor(.orange) + + Text("No Authentication Methods") + .authFont(.title2) + .fontWeight(.semibold) + + Text( + "Set up two-factor authentication to add an extra layer of security to your account." + ) + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button { + state.goToEnrollment() + } label: { + Text("Set Up Two-Factor Authentication") + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("setup-mfa-button") + } + } else { + // Show enrolled factors + VStack(alignment: .leading, spacing: 16) { + Text("Enrolled Methods") + .authFont(.headline) + .padding(.horizontal) + + ForEach(state.enrolledFactors) { factor in + factorRow(factor: factor) + } + + Divider() + .padding(.horizontal) + + Button("Add Another Method") { + state.goToEnrollment() + } + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .authCTAButtonStyle() + .accessibilityIdentifier("add-mfa-method-button") + } + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .safeAreaPadding() + } + + @ViewBuilder + private func factorRow(factor: MultiFactorInfo) -> some View { + HStack { + // Factor type icon + Group { + if factor.factorID == PhoneMultiFactorID { + Image(systemName: "message") + .foregroundColor(.blue) + } else { + Image(systemName: "qrcode") + .foregroundColor(.green) + } + } + .authFont(.title2) + + VStack(alignment: .leading, spacing: 4) { + Text(factor.displayName ?? authService.string.unnamedMethodLabel) + .authFont(.headline) + + if factor.factorID == PhoneMultiFactorID { + let phoneInfo = factor as! PhoneMultiFactorInfo + Text("SMS: \(phoneInfo.phoneNumber)") + .authFont(.caption) + .foregroundColor(.secondary) + } else { + Text("Authenticator App") + .authFont(.caption) + .foregroundColor(.secondary) + } + + Text("Enrolled: \(DateFormatter.shortDate.string(from: factor.enrollmentDate))") + .authFont(.caption2) + .foregroundColor(.secondary) + } + + Spacer() + + Button("Remove") { + state.unenroll(factor.uid) + } + .buttonStyle(.bordered) + .foregroundColor(.red) + .disabled(state.isLoading) + .accessibilityIdentifier("remove-factor-\(factor.uid)") + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + .padding(.horizontal) + } +} + +// MARK: - Date Formatter Extension + +private extension DateFormatter { + static let shortDate: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .none + return formatter + }() +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAResolutionContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAResolutionContent.swift new file mode 100644 index 0000000000..8297e047fd --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAResolutionContent.swift @@ -0,0 +1,284 @@ +// 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 ``MFAResolutionScreen``. +@MainActor +public struct DefaultMFAResolutionContent: View { + public init(state: MFAResolutionContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: MFAResolutionContentState + + private var selectedHint: MFAHint? { + guard state.hints.indices.contains(state.selectedHintIndex.wrappedValue) else { + return nil + } + return state.hints[state.selectedHintIndex.wrappedValue] + } + + public var body: some View { + VStack(spacing: 24) { + // Header + VStack(spacing: 12) { + Image(systemName: "lock.shield") + .font(.system(size: 50)) + .foregroundColor(.blue) + + Text("Two-Factor Authentication") + .authFont(.largeTitle) + .fontWeight(.bold) + .accessibilityIdentifier("mfa-resolution-title") + + Text("Complete sign-in with your second factor") + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal) + + // MFA Hints Selection (if multiple available) + if state.hints.count > 1 { + mfaHintsSelectionView + } + + // Resolution Content + if let hint = selectedHint { + resolutionContent(for: hint) + } + + // Action buttons + VStack(spacing: 12) { + // Complete Resolution Button + Button(action: state.resolve) { + HStack { + if state.isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Complete Sign-In") + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .disabled(state.isLoading || !state.isValid) + .accessibilityIdentifier("complete-resolution-button") + + // Cancel Button + Button(action: state.cancel) { + Text("Cancel") + .authCustomFont(.body) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .accessibilityIdentifier("cancel-button") + } + .padding(.horizontal) + } + .padding(.vertical, 20) + } + + @ViewBuilder + private func resolutionContent(for hint: MFAHint) -> some View { + switch hint { + case let .phone(displayName, _, phoneNumber): + phoneResolutionContent(displayName: displayName, phoneNumber: phoneNumber) + case let .totp(displayName, _): + totpResolutionContent(displayName: displayName) + } + } + + @ViewBuilder + private func phoneResolutionContent(displayName _: String?, phoneNumber: String?) -> some View { + VStack(spacing: 16) { + VStack(spacing: 8) { + Image(systemName: "message.circle.fill") + .font(.system(size: 40)) + .foregroundColor(.blue) + + Text("SMS Verification") + .authFont(.title2) + .fontWeight(.semibold) + + if let phoneNumber = phoneNumber { + Text("We'll send a code to ••••••\(String(phoneNumber.suffix(4)))") + .authFont(.body) + .foregroundColor(.secondary) + } else { + Text("We'll send a verification code to your phone") + .authFont(.body) + .foregroundColor(.secondary) + } + } + .padding(.horizontal) + + // Send SMS button (if verification ID not yet obtained) + if !state.isCodeSent { + Button(action: state.sendCode) { + HStack { + if state.isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Send Code") + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .disabled(state.isLoading) + .padding(.horizontal) + .accessibilityIdentifier("send-sms-button") + } else { + // Verification code input + VStack(alignment: .leading, spacing: 8) { + Text("Verification Code") + .authFont(.headline) + + VerificationCodeInputField( + code: state.verificationCode, + validations: [FormValidators.verificationCode], + maintainsValidationMessage: true + ) + .accessibilityIdentifier("sms-verification-code-field") + } + .padding(.horizontal) + } + } + } + + @ViewBuilder + private func totpResolutionContent(displayName: String?) -> some View { + VStack(spacing: 16) { + VStack(spacing: 8) { + Image(systemName: "qrcode") + .font(.system(size: 40)) + .foregroundColor(.green) + + Text("Authenticator App") + .authFont(.title2) + .fontWeight(.semibold) + + Text("Enter the 6-digit code from your authenticator app") + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + if let displayName = displayName { + Text(authService.string.accountPrefix(displayName: displayName)) + .authFont(.caption) + .foregroundColor(.secondary) + } + } + .padding(.horizontal) + + // TOTP code input + VStack(alignment: .leading, spacing: 8) { + Text("Verification Code") + .authFont(.headline) + + VerificationCodeInputField( + code: state.totpCode, + validations: [FormValidators.verificationCode], + maintainsValidationMessage: true + ) + .accessibilityIdentifier("totp-verification-code-field") + } + .padding(.horizontal) + } + } + + @ViewBuilder + private var mfaHintsSelectionView: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Choose verification method:") + .authFont(.headline) + .padding(.horizontal) + + ForEach(state.hints.indices, id: \.self) { index in + let hint = state.hints[index] + hintSelectionButton(hint: hint, index: index) + } + } + } + + @ViewBuilder + private func hintSelectionButton(hint: MFAHint, index: Int) -> some View { + let isSelected = state.selectedHintIndex.wrappedValue == index + Button(action: { + state.selectedHintIndex.wrappedValue = index + }) { + HStack { + Image(systemName: hint.isPhoneHint ? "message.circle" : "qrcode") + .foregroundColor(.blue) + + VStack(alignment: .leading) { + Text(hintDisplayName(for: hint)) + .authFont(.body) + .foregroundColor(.primary) + + hintSubtitle(for: hint) + } + + Spacer() + + if isSelected { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.blue) + } + } + .padding() + .background(isSelected ? Color.blue.opacity(0.1) : Color.clear) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isSelected ? Color.blue : Color.gray.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(PlainButtonStyle()) + .padding(.horizontal) + .accessibilityIdentifier("hint-\(index)") + } + + private func hintDisplayName(for hint: MFAHint) -> String { + hint.isPhoneHint ? "SMS" : "Authenticator App" + } + + @ViewBuilder + private func hintSubtitle(for hint: MFAHint) -> some View { + if case let .phone(_, _, phoneNumber) = hint, let phone = phoneNumber { + Text("••••••\(String(phone.suffix(4)))") + .authFont(.caption) + .foregroundColor(.secondary) + } + } +} + +// Helper extension for MFAHint +private extension MFAHint { + var isPhoneHint: Bool { + switch self { + case .phone: + return true + case .totp: + return false + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementScreen.swift new file mode 100644 index 0000000000..9048b5eaef --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementScreen.swift @@ -0,0 +1,142 @@ +// 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 MFA management screen, passed to ``MFAManagementScreen``'s +/// content. +public struct MFAManagementContentState { + /// Removes the enrolled factor with this `uid`, then updates ``enrolledFactors``. Asks the user + /// to reauthenticate first when Firebase requires it. Does nothing while ``isLoading`` is + /// `true`. + public let unenroll: @MainActor (_ factorUid: String) -> Void + + /// Pushes ``AuthView/mfaEnrollment``. + public let goToEnrollment: @MainActor () -> Void + + /// Reloads ``enrolledFactors`` from the current user. + public let refresh: @MainActor () -> Void + + /// The current user's enrolled second factors. + public let enrolledFactors: [MultiFactorInfo] + + /// Whether an unenroll, including its reauthentication, is in flight, or the ``AuthService`` + /// is signing in. + public let isLoading: Bool + + /// The localized message of the last failed unenroll. It clears when 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(unenroll: @escaping @MainActor (_ factorUid: String) -> Void, + goToEnrollment: @escaping @MainActor () -> Void, + refresh: @escaping @MainActor () -> Void, + enrolledFactors: [MultiFactorInfo], + isLoading: Bool, + errorMessage: String?) { + self.unenroll = unenroll + self.goToEnrollment = goToEnrollment + self.refresh = refresh + self.enrolledFactors = enrolledFactors + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The MFA management screen, with its content drawn by you. +/// +/// The screen loads the enrolled factors, unenrolls them, presents reauthentication when +/// Firebase requires it, navigates, and reports errors. Your content only draws the UI from the +/// ``MFAManagementContentState`` it receives. Wrap ``DefaultMFAManagementContent`` to keep the +/// stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case .mfaManagement: +/// MFAManagementScreen { state in +/// ForEach(state.enrolledFactors) { factor in +/// Button("Remove \(factor.displayName ?? factor.uid)") { +/// state.unenroll(factor.uid) +/// } +/// .disabled(state.isLoading) +/// } +/// Button("Add method", action: state.goToEnrollment) +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct MFAManagementScreen: View { + public init(@ViewBuilder content: @escaping (MFAManagementContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var enrolledFactors: [MultiFactorInfo] = [] + @State private var isWorking = false + @State private var errorMessage: String? + @State private var reauthCoordinator = ReauthenticationCoordinator() + + private let content: (MFAManagementContentState) -> Content + + public var body: some View { + content(MFAManagementContentState( + unenroll: unenroll, + goToEnrollment: { authService.navigator.push(.mfaEnrollment) }, + refresh: refresh, + enrolledFactors: enrolledFactors, + isLoading: isLoading, + errorMessage: errorMessage + )) + .withReauthentication(coordinator: reauthCoordinator) + .onAppear(perform: refresh) + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func refresh() { + guard let user = authService.currentUser else { return } + enrolledFactors = user.multiFactor.enrolledFactors + } + + private func unenroll(_ factorUid: String) { + guard !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + try await withReauthenticationIfNeeded( + authService: authService, + coordinator: reauthCoordinator + ) { + enrolledFactors = try await authService.unenrollMFA(factorUid) + } + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift index b8a982ee7f..183ea7e8cf 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift @@ -22,190 +22,15 @@ extension MultiFactorInfo: @retroactive Identifiable { @MainActor public struct MFAManagementView { - @Environment(AuthService.self) private var authService - @Environment(\.reportError) private var reportError - - @State private var enrolledFactors: [MultiFactorInfo] = [] - @State private var isLoading = false - @State private var reauthCoordinator = ReauthenticationCoordinator() - public init() {} - - private func loadEnrolledFactors() { - guard let user = authService.currentUser else { return } - enrolledFactors = user.multiFactor.enrolledFactors - } - - private func unenrollFactor(_ factorUid: String) { - Task { - isLoading = true - - do { - try await withReauthenticationIfNeeded( - authService: authService, - coordinator: reauthCoordinator - ) { - let freshFactors = try await authService.unenrollMFA(factorUid) - enrolledFactors = freshFactors - } - isLoading = false - } catch { - reportError?(error) - isLoading = false - } - } - } - - private func navigateToEnrollment() { - authService.navigator.push(.mfaEnrollment) - } } extension MFAManagementView: View { public var body: some View { - VStack(spacing: 20) { - // Title section - VStack { - Text("Two-Factor Authentication") - .authFont(.largeTitle) - .fontWeight(.bold) - .multilineTextAlignment(.center) - - Text("Manage your authentication methods") - .authFont(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .padding(.horizontal) - - if enrolledFactors.isEmpty { - // No factors enrolled - VStack(spacing: 16) { - Image(systemName: "shield.slash") - .font(.system(size: 48)) - .foregroundColor(.orange) - - Text("No Authentication Methods") - .authFont(.title2) - .fontWeight(.semibold) - - Text( - "Set up two-factor authentication to add an extra layer of security to your account." - ) - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - - Button { - navigateToEnrollment() - } label: { - Text("Set Up Two-Factor Authentication") - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("setup-mfa-button") - } - } else { - // Show enrolled factors - VStack(alignment: .leading, spacing: 16) { - Text("Enrolled Methods") - .authFont(.headline) - .padding(.horizontal) - - ForEach(enrolledFactors) { factor in - factorRow(factor: factor) - } - - Divider() - .padding(.horizontal) - - Button("Add Another Method") { - navigateToEnrollment() - } - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .authCTAButtonStyle() - .accessibilityIdentifier("add-mfa-method-button") - } - } - - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .safeAreaPadding() - .withReauthentication(coordinator: reauthCoordinator) - .onAppear { - loadEnrolledFactors() - } - } - - @ViewBuilder - private func factorRow(factor: MultiFactorInfo) -> some View { - HStack { - // Factor type icon - Group { - if factor.factorID == PhoneMultiFactorID { - Image(systemName: "message") - .foregroundColor(.blue) - } else { - Image(systemName: "qrcode") - .foregroundColor(.green) - } - } - .authFont(.title2) - - VStack(alignment: .leading, spacing: 4) { - Text(factor.displayName ?? authService.string.unnamedMethodLabel) - .authFont(.headline) - - if factor.factorID == PhoneMultiFactorID { - let phoneInfo = factor as! PhoneMultiFactorInfo - Text("SMS: \(phoneInfo.phoneNumber)") - .authFont(.caption) - .foregroundColor(.secondary) - } else { - Text("Authenticator App") - .authFont(.caption) - .foregroundColor(.secondary) - } - - Text("Enrolled: \(DateFormatter.shortDate.string(from: factor.enrollmentDate))") - .authFont(.caption2) - .foregroundColor(.secondary) - } - - Spacer() - - Button("Remove") { - unenrollFactor(factor.uid) - } - .buttonStyle(.bordered) - .foregroundColor(.red) - .disabled(isLoading) - .accessibilityIdentifier("remove-factor-\(factor.uid)") - } - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - .padding(.horizontal) + MFAManagementScreen { DefaultMFAManagementContent(state: $0) } } } -// MARK: - Date Formatter Extension - -private extension DateFormatter { - static let shortDate: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .none - return formatter - }() -} - #Preview { FirebaseOptions.dummyConfigurationForPreview() return NavigationStack { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionScreen.swift new file mode 100644 index 0000000000..65883da8a5 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionScreen.swift @@ -0,0 +1,211 @@ +// 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 MFA resolution screen, passed to ``MFAResolutionScreen``'s +/// content. +public struct MFAResolutionContentState { + /// The index in ``hints`` of the factor the user resolves with. Changing it clears + /// ``verificationCode``, ``totpCode`` and ``isCodeSent``. + public let selectedHintIndex: Binding + + /// The SMS code the user typed, for a phone hint. + public let verificationCode: Binding + + /// The authenticator app code the user typed, for a TOTP hint. + public let totpCode: Binding + + /// Sends an SMS code to the selected phone hint and sets ``isCodeSent``. Does nothing unless + /// the selected hint is a phone hint and ``isLoading`` is `false`. + public let sendCode: @MainActor () -> Void + + /// Completes sign-in with the code for the selected hint, then clears navigation. Does nothing + /// unless ``isValid`` is `true` and ``isLoading`` is `false`. + public let resolve: @MainActor () -> Void + + /// Abandons sign-in and clears navigation. + public let cancel: @MainActor () -> Void + + /// The second factors the user can resolve with. + public let hints: [MFAHint] + + /// Whether an SMS code was sent for the selected phone hint. + public let isCodeSent: Bool + + /// Whether the code for the selected hint is non-empty. + public let isValid: Bool + + /// Whether a send or resolve is in flight, or the ``AuthService`` is signing in. + public let isLoading: Bool + + /// The localized message of the last failed send or resolve. It clears when a code or the + /// selected hint 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(selectedHintIndex: Binding, + verificationCode: Binding, + totpCode: Binding, + sendCode: @escaping @MainActor () -> Void, + resolve: @escaping @MainActor () -> Void, + cancel: @escaping @MainActor () -> Void, + hints: [MFAHint], + isCodeSent: Bool, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.selectedHintIndex = selectedHintIndex + self.verificationCode = verificationCode + self.totpCode = totpCode + self.sendCode = sendCode + self.resolve = resolve + self.cancel = cancel + self.hints = hints + self.isCodeSent = isCodeSent + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The MFA resolution screen, with its content drawn by you. +/// +/// The screen sends SMS codes, completes sign-in with the second factor, navigates, and reports +/// errors. Your content only draws the UI from the ``MFAResolutionContentState`` it receives. +/// Wrap ``DefaultMFAResolutionContent`` to keep the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case let .mfaResolution(mfaRequired): +/// MFAResolutionScreen(mfaRequired: mfaRequired) { state in +/// TextField("Code", text: state.totpCode) +/// Button("Verify", action: state.resolve) +/// .disabled(!state.isValid || state.isLoading) +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct MFAResolutionScreen: View { + public init(mfaRequired: MFARequired, + @ViewBuilder content: @escaping (MFAResolutionContentState) -> Content) { + self.mfaRequired = mfaRequired + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var selectedHintIndex = 0 + @State private var verificationCode = "" + @State private var totpCode = "" + @State private var verificationId: String? + @State private var isWorking = false + @State private var errorMessage: String? + + private let mfaRequired: MFARequired + private let content: (MFAResolutionContentState) -> Content + + public var body: some View { + content(MFAResolutionContentState( + selectedHintIndex: $selectedHintIndex, + verificationCode: $verificationCode, + totpCode: $totpCode, + sendCode: sendCode, + resolve: resolve, + cancel: { authService.navigator.clear() }, + hints: mfaRequired.hints, + isCodeSent: verificationId != nil, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .onChange(of: selectedHintIndex) { + verificationCode = "" + totpCode = "" + verificationId = nil + errorMessage = nil + } + .onChange(of: verificationCode) { errorMessage = nil } + .onChange(of: totpCode) { errorMessage = nil } + } + + private var selectedHint: MFAHint? { + guard mfaRequired.hints.indices.contains(selectedHintIndex) else { return nil } + return mfaRequired.hints[selectedHintIndex] + } + + private var isValid: Bool { + switch selectedHint { + case .phone: + return !verificationCode.isEmpty + case .totp: + return !totpCode.isEmpty + case .none: + return false + } + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func sendCode() { + guard case .phone = selectedHint, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + verificationId = try await authService.resolveSmsChallenge(hintIndex: selectedHintIndex) + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } + + private func resolve() { + guard let selectedHint, isValid, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + let code: String + switch selectedHint { + case .phone: + code = verificationCode + case .totp: + code = totpCode + } + try await authService.resolveSignIn( + code: code, + hintIndex: selectedHintIndex, + verificationId: verificationId + ) + authService.navigator.clear() + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift index e3dd9a28c2..b53e34d81d 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift @@ -12,8 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -21,336 +19,14 @@ import SwiftUI public struct MFAResolutionView { let mfaRequired: MFARequired - @Environment(AuthService.self) private var authService - @Environment(\.reportError) private var reportError - - @State private var verificationCode = "" - @State private var totpCode = "" - @State private var isLoading = false - @State private var selectedHintIndex = 0 - @State private var verificationId: String? - public init(mfaRequired: MFARequired) { self.mfaRequired = mfaRequired } - - private var selectedHint: MFAHint? { - guard selectedHintIndex < mfaRequired.hints.count else { - return nil - } - return mfaRequired.hints[selectedHintIndex] - } - - private var canCompleteResolution: Bool { - guard !isLoading else { return false } - - switch selectedHint { - case .phone: - return !verificationCode.isEmpty - case .totp: - return !totpCode.isEmpty - case .none: - return false - } - } - - private func startSMSChallenge() { - guard selectedHintIndex < mfaRequired.hints.count else { return } - - Task { - isLoading = true - - do { - let verificationId = try await authService.resolveSmsChallenge(hintIndex: selectedHintIndex) - self.verificationId = verificationId - isLoading = false - } catch { - reportError?(error) - isLoading = false - } - } - } - - private func completeResolution() { - Task { - isLoading = true - - do { - let code = selectedHint?.isPhoneHint == true ? verificationCode : totpCode - try await authService.resolveSignIn( - code: code, - hintIndex: selectedHintIndex, - verificationId: verificationId - ) - // On success, the AuthService will update the authentication state - // and we should navigate back to the main app - authService.navigator.clear() - isLoading = false - } catch { - reportError?(error) - isLoading = false - } - } - } - - private func cancelResolution() { - authService.navigator.clear() - } } extension MFAResolutionView: View { public var body: some View { - VStack(spacing: 24) { - // Header - VStack(spacing: 12) { - Image(systemName: "lock.shield") - .font(.system(size: 50)) - .foregroundColor(.blue) - - Text("Two-Factor Authentication") - .authFont(.largeTitle) - .fontWeight(.bold) - .accessibilityIdentifier("mfa-resolution-title") - - Text("Complete sign-in with your second factor") - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .padding(.horizontal) - - // MFA Hints Selection (if multiple available) - if mfaRequired.hints.count > 1 { - mfaHintsSelectionView(mfaRequired: mfaRequired) - } - - // Resolution Content - if let hint = selectedHint { - resolutionContent(for: hint) - } - - // Action buttons - VStack(spacing: 12) { - // Complete Resolution Button - Button(action: completeResolution) { - HStack { - if isLoading { - ProgressView() - .scaleEffect(0.8) - } - Text("Complete Sign-In") - } - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .disabled(!canCompleteResolution) - .accessibilityIdentifier("complete-resolution-button") - - // Cancel Button - Button(action: cancelResolution) { - Text("Cancel") - .authCustomFont(.body) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .accessibilityIdentifier("cancel-button") - } - .padding(.horizontal) - } - .padding(.vertical, 20) - } - - @ViewBuilder - private func resolutionContent(for hint: MFAHint) -> some View { - switch hint { - case let .phone(displayName, _, phoneNumber): - phoneResolutionContent(displayName: displayName, phoneNumber: phoneNumber) - case let .totp(displayName, _): - totpResolutionContent(displayName: displayName) - } - } - - @ViewBuilder - private func phoneResolutionContent(displayName _: String?, phoneNumber: String?) -> some View { - VStack(spacing: 16) { - VStack(spacing: 8) { - Image(systemName: "message.circle.fill") - .font(.system(size: 40)) - .foregroundColor(.blue) - - Text("SMS Verification") - .authFont(.title2) - .fontWeight(.semibold) - - if let phoneNumber = phoneNumber { - Text("We'll send a code to ••••••\(String(phoneNumber.suffix(4)))") - .authFont(.body) - .foregroundColor(.secondary) - } else { - Text("We'll send a verification code to your phone") - .authFont(.body) - .foregroundColor(.secondary) - } - } - .padding(.horizontal) - - // Send SMS button (if verification ID not yet obtained) - if verificationId == nil { - Button(action: startSMSChallenge) { - HStack { - if isLoading { - ProgressView() - .scaleEffect(0.8) - } - Text("Send Code") - } - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .disabled(isLoading) - .padding(.horizontal) - .accessibilityIdentifier("send-sms-button") - } else { - // Verification code input - VStack(alignment: .leading, spacing: 8) { - Text("Verification Code") - .authFont(.headline) - - VerificationCodeInputField( - code: $verificationCode, - validations: [FormValidators.verificationCode], - maintainsValidationMessage: true - ) - .accessibilityIdentifier("sms-verification-code-field") - } - .padding(.horizontal) - } - } - } - - @ViewBuilder - private func totpResolutionContent(displayName: String?) -> some View { - VStack(spacing: 16) { - VStack(spacing: 8) { - Image(systemName: "qrcode") - .font(.system(size: 40)) - .foregroundColor(.green) - - Text("Authenticator App") - .authFont(.title2) - .fontWeight(.semibold) - - Text("Enter the 6-digit code from your authenticator app") - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - - if let displayName = displayName { - Text(authService.string.accountPrefix(displayName: displayName)) - .authFont(.caption) - .foregroundColor(.secondary) - } - } - .padding(.horizontal) - - // TOTP code input - VStack(alignment: .leading, spacing: 8) { - Text("Verification Code") - .authFont(.headline) - - VerificationCodeInputField( - code: $totpCode, - validations: [FormValidators.verificationCode], - maintainsValidationMessage: true - ) - .accessibilityIdentifier("totp-verification-code-field") - } - .padding(.horizontal) - } - } - - @ViewBuilder - private func mfaHintsSelectionView(mfaRequired: MFARequired) -> some View { - VStack(alignment: .leading, spacing: 12) { - Text("Choose verification method:") - .authFont(.headline) - .padding(.horizontal) - - // More idiomatic approach using indices - ForEach(mfaRequired.hints.indices, id: \.self) { index in - let hint = mfaRequired.hints[index] - hintSelectionButton(hint: hint, index: index) - } - } - } - - @ViewBuilder - private func hintSelectionButton(hint: MFAHint, index: Int) -> some View { - Button(action: { - selectedHintIndex = index - // Clear previous input when switching methods - verificationCode = "" - totpCode = "" - verificationId = nil - }) { - HStack { - Image(systemName: hint.isPhoneHint ? "message.circle" : "qrcode") - .foregroundColor(.blue) - - VStack(alignment: .leading) { - Text(hintDisplayName(for: hint)) - .authFont(.body) - .foregroundColor(.primary) - - hintSubtitle(for: hint) - } - - Spacer() - - if selectedHintIndex == index { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.blue) - } - } - .padding() - .background(selectedHintIndex == index ? Color.blue.opacity(0.1) : Color.clear) - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(selectedHintIndex == index ? Color.blue : Color.gray.opacity(0.3), lineWidth: 1) - ) - } - .buttonStyle(PlainButtonStyle()) - .padding(.horizontal) - .accessibilityIdentifier("hint-\(index)") - } - - private func hintDisplayName(for hint: MFAHint) -> String { - hint.isPhoneHint ? "SMS" : "Authenticator App" - } - - @ViewBuilder - private func hintSubtitle(for hint: MFAHint) -> some View { - if case let .phone(_, _, phoneNumber) = hint, let phone = phoneNumber { - Text("••••••\(String(phone.suffix(4)))") - .authFont(.caption) - .foregroundColor(.secondary) - } - } -} - -// Helper extension for MFAHint -private extension MFAHint { - var isPhoneHint: Bool { - switch self { - case .phone: - return true - case .totp: - return false - } + MFAResolutionScreen(mfaRequired: mfaRequired) { DefaultMFAResolutionContent(state: $0) } } } From e1da9689bd2b9a4fe2bcccde8899c6cc0ffe9312 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:24:26 +0100 Subject: [PATCH 2/2] fix(auth): attach MFA screen modifiers once and tighten their state Wrap each MFA screen's content in one container so the reauthentication sheets and lifecycle modifiers attach once, even when the content returns several views or none. Setting selectedHintIndex resets the challenge again on every tap, a code sent for a hint the user switched away from is dropped, cancelling reauthentication no longer sets errorMessage, and refresh clears it. --- .../Sources/Views/MFAManagementScreen.swift | 34 +++++++----- .../Sources/Views/MFAResolutionScreen.swift | 52 +++++++++++-------- 2 files changed, 53 insertions(+), 33 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementScreen.swift index 9048b5eaef..520d76f9eb 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementScreen.swift @@ -26,7 +26,7 @@ public struct MFAManagementContentState { /// Pushes ``AuthView/mfaEnrollment``. public let goToEnrollment: @MainActor () -> Void - /// Reloads ``enrolledFactors`` from the current user. + /// Re-reads ``enrolledFactors`` from the signed-in user and clears ``errorMessage``. public let refresh: @MainActor () -> Void /// The current user's enrolled second factors. @@ -36,8 +36,9 @@ public struct MFAManagementContentState { /// is signing in. public let isLoading: Bool - /// The localized message of the last failed unenroll. It clears when the next attempt starts. - /// The sheet's error alert still shows for the same failure. + /// The localized message of the last failed unenroll; cancelling reauthentication sets none. It + /// clears on ``refresh`` or when 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. @@ -99,14 +100,18 @@ public struct MFAManagementScreen: View { private let content: (MFAManagementContentState) -> Content public var body: some View { - content(MFAManagementContentState( - unenroll: unenroll, - goToEnrollment: { authService.navigator.push(.mfaEnrollment) }, - refresh: refresh, - enrolledFactors: enrolledFactors, - isLoading: isLoading, - errorMessage: errorMessage - )) + // A single container, so the reauthentication sheets attach once even when the content + // returns several views, or none. + VStack(spacing: 0) { + content(MFAManagementContentState( + unenroll: unenroll, + goToEnrollment: { authService.navigator.push(.mfaEnrollment) }, + refresh: refresh, + enrolledFactors: enrolledFactors, + isLoading: isLoading, + errorMessage: errorMessage + )) + } .withReauthentication(coordinator: reauthCoordinator) .onAppear(perform: refresh) } @@ -116,6 +121,7 @@ public struct MFAManagementScreen: View { } private func refresh() { + errorMessage = nil guard let user = authService.currentUser else { return } enrolledFactors = user.multiFactor.enrolledFactors } @@ -134,7 +140,11 @@ public struct MFAManagementScreen: View { enrolledFactors = try await authService.unenrollMFA(factorUid) } } catch { - errorMessage = authService.string.localizedErrorMessage(for: error) + if case AuthServiceError.signInCancelled = error { + // The user backed out of reauthentication; nothing failed. + } else { + errorMessage = authService.string.localizedErrorMessage(for: error) + } reportError?(error) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionScreen.swift index 65883da8a5..a764de6f45 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionScreen.swift @@ -17,8 +17,8 @@ import SwiftUI /// The state and actions of the MFA resolution screen, passed to ``MFAResolutionScreen``'s /// content. public struct MFAResolutionContentState { - /// The index in ``hints`` of the factor the user resolves with. Changing it clears - /// ``verificationCode``, ``totpCode`` and ``isCodeSent``. + /// The index in ``hints`` of the factor the user resolves with. Setting it, even to the + /// current index, clears ``verificationCode``, ``totpCode`` and ``isCodeSent``. public let selectedHintIndex: Binding /// The SMS code the user typed, for a phone hint. @@ -124,29 +124,35 @@ public struct MFAResolutionScreen: View { private let content: (MFAResolutionContentState) -> Content public var body: some View { - content(MFAResolutionContentState( - selectedHintIndex: $selectedHintIndex, - verificationCode: $verificationCode, - totpCode: $totpCode, - sendCode: sendCode, - resolve: resolve, - cancel: { authService.navigator.clear() }, - hints: mfaRequired.hints, - isCodeSent: verificationId != nil, - isValid: isValid, - isLoading: isLoading, - errorMessage: errorMessage - )) - .onChange(of: selectedHintIndex) { - verificationCode = "" - totpCode = "" - verificationId = nil - errorMessage = nil + // A single container, so the modifiers attach once even when the content returns several + // views. + VStack(spacing: 0) { + content(MFAResolutionContentState( + selectedHintIndex: Binding(get: { selectedHintIndex }, set: selectHint), + verificationCode: $verificationCode, + totpCode: $totpCode, + sendCode: sendCode, + resolve: resolve, + cancel: { authService.navigator.clear() }, + hints: mfaRequired.hints, + isCodeSent: verificationId != nil, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) } .onChange(of: verificationCode) { errorMessage = nil } .onChange(of: totpCode) { errorMessage = nil } } + private func selectHint(_ index: Int) { + selectedHintIndex = index + verificationCode = "" + totpCode = "" + verificationId = nil + errorMessage = nil + } + private var selectedHint: MFAHint? { guard mfaRequired.hints.indices.contains(selectedHintIndex) else { return nil } return mfaRequired.hints[selectedHintIndex] @@ -171,10 +177,14 @@ public struct MFAResolutionScreen: View { guard case .phone = selectedHint, !isLoading else { return } isWorking = true errorMessage = nil + let hintIndex = selectedHintIndex Task { defer { isWorking = false } do { - verificationId = try await authService.resolveSmsChallenge(hintIndex: selectedHintIndex) + let id = try await authService.resolveSmsChallenge(hintIndex: hintIndex) + // Drop a code sent for a hint the user has since switched away from. + guard selectedHintIndex == hintIndex else { return } + verificationId = id } catch { errorMessage = authService.string.localizedErrorMessage(for: error) reportError?(error)