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..520d76f9eb --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementScreen.swift @@ -0,0 +1,152 @@ +// 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 + + /// Re-reads ``enrolledFactors`` from the signed-in user and clears ``errorMessage``. + 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; 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. + 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 { + // 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) + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func refresh() { + errorMessage = nil + 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 { + 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/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..a764de6f45 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionScreen.swift @@ -0,0 +1,221 @@ +// 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. 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. + 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 { + // 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] + } + + 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 + let hintIndex = selectedHintIndex + Task { + defer { isWorking = false } + do { + 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) + } + } + } + + 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) } } }