From 6eb7e2cc85add0361a37254c312769e23fa6cdeb Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:26:09 +0100 Subject: [PATCH 1/7] feat(auth): add content slots to phone number and verification code screens Add EnterPhoneNumberScreen and EnterVerificationCodeScreen, generic over content that receives a PhoneNumberContentState or VerificationCodeContentState. The screens own the input state and the send, verify, resend and change-number actions; the content only draws. DefaultEnterPhoneNumberContent and DefaultEnterVerificationCodeContent hold the stock markup, and the existing views render them through the new screens. The verification code screen adds a resend button with a 60 second countdown. Verifying now goes through signIn(credentials:), so an MFA challenge reaches the resolution screen instead of being dropped. isLoading also covers sending the code, which previously showed no progress and allowed repeated taps. --- .../DefaultEnterPhoneNumberContent.swift | 74 ++++++++ .../DefaultEnterVerificationCodeContent.swift | 91 ++++++++++ .../Views/EnterPhoneNumberScreen.swift | 108 ++++++++++++ .../Sources/Views/EnterPhoneNumberView.swift | 69 +------- .../Views/EnterVerificationCodeScreen.swift | 163 ++++++++++++++++++ .../Views/EnterVerificationCodeView.swift | 79 +-------- 6 files changed, 441 insertions(+), 143 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterPhoneNumberContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterPhoneNumberContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterPhoneNumberContent.swift new file mode 100644 index 0000000000..1b5745afa8 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterPhoneNumberContent.swift @@ -0,0 +1,74 @@ +// 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 ``EnterPhoneNumberScreen``. +@MainActor +public struct DefaultEnterPhoneNumberContent: View { + public init(state: PhoneNumberContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: PhoneNumberContentState + + public var body: some View { + VStack(spacing: 16) { + Text(authService.string.enterPhoneNumberPlaceholder) + .authFont(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top) + + AuthTextField( + text: state.phoneNumber, + label: authService.string.phoneFieldLabel, + prompt: authService.string.enterPhoneNumberPlaceholder, + keyboardType: .phonePad, + contentType: .telephoneNumber, + validations: [ + FormValidators.phoneNumber, + ], + onChange: { _ in } + ) { + CountrySelector( + selectedCountry: state.selectedCountry, + enabled: !state.isLoading + ) + } + + Button(action: state.sendCode) { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.sendCodeButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .authCTAButtonStyle() + .disabled(state.isLoading || !state.isValid) + .padding(.top, 8) + + Spacer() + } + .navigationTitle(authService.string.phoneSignInTitle) + .padding(.horizontal) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift new file mode 100644 index 0000000000..4802e87953 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift @@ -0,0 +1,91 @@ +// 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 ``EnterVerificationCodeScreen``. +@MainActor +public struct DefaultEnterVerificationCodeContent: View { + public init(state: VerificationCodeContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: VerificationCodeContentState + + public var body: some View { + VStack(spacing: 32) { + VStack(spacing: 16) { + VStack(spacing: 8) { + Text(authService.string.sentCodeMessage(phoneNumber: state.fullPhoneNumber)) + .authFont(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + + Button(action: state.changeNumber) { + Text(authService.string.changeNumberButtonLabel) + .authFont(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.bottom) + .frame(maxWidth: .infinity, alignment: .leading) + + VerificationCodeInputField( + code: state.verificationCode, + validations: [ + FormValidators.verificationCode, + ], + maintainsValidationMessage: true + ) + + Button(action: state.verify) { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.verifyAndSignInButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .authCTAButtonStyle() + .disabled(state.isLoading || !state.isValid) + + Button(action: state.resendCode) { + Text(resendLabel) + .authFont(.body) + .frame(maxWidth: .infinity) + } + .disabled(state.isLoading || state.resendCountdown > 0) + .accessibilityIdentifier("resend-code-button") + } + + Spacer() + } + .navigationTitle(authService.string.enterVerificationCodeTitle) + .navigationBarTitleDisplayMode(.large) + .padding(.horizontal) + } + + private var resendLabel: String { + guard state.resendCountdown > 0 else { return authService.string.resendCodeLabel } + let remaining = Duration.seconds(state.resendCountdown) + .formatted(.time(pattern: .minuteSecond)) + return "\(authService.string.resendCodeLabel) (\(remaining))" + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift new file mode 100644 index 0000000000..5ffc5af9d8 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift @@ -0,0 +1,108 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The state and actions of the phone number screen, passed to ``EnterPhoneNumberScreen``'s +/// content. +public struct PhoneNumberContentState { + /// The national phone number, without the dial code. + public let phoneNumber: Binding + public let selectedCountry: Binding + /// Sends a verification code to the full phone number, then pushes + /// ``AuthView/enterVerificationCode(verificationID:fullPhoneNumber:)``. + public let sendCode: () -> Void + public let isValid: Bool + public let isLoading: Bool + /// The localized message of the last failed attempt, if any. + public let errorMessage: String? +} + +/// The phone number screen, with its content drawn by you. +/// +/// The screen sends the code, navigates, and reports errors. Your content only draws the UI from +/// the ``PhoneNumberContentState`` it receives. Wrap ``DefaultEnterPhoneNumberContent`` to keep +/// the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case .enterPhoneNumber: +/// EnterPhoneNumberScreen { state in +/// TextField("Phone number", text: state.phoneNumber) +/// Button("Send code", action: state.sendCode) +/// .disabled(!state.isValid || state.isLoading) +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct EnterPhoneNumberScreen: View { + public init(@ViewBuilder content: @escaping (PhoneNumberContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var phoneNumber: String = "" + @State private var selectedCountry: CountryData = .default + @State private var isSending = false + @State private var errorMessage: String? + + private let content: (PhoneNumberContentState) -> Content + + public var body: some View { + content(PhoneNumberContentState( + phoneNumber: $phoneNumber, + selectedCountry: $selectedCountry, + sendCode: sendCode, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + } + + private var isValid: Bool { + !phoneNumber.isEmpty + } + + private var isLoading: Bool { + isSending || authService.authenticationState == .authenticating + } + + private func sendCode() { + guard isValid, !isLoading else { return } + Task { + isSending = true + errorMessage = nil + defer { isSending = false } + do { + let fullPhoneNumber = selectedCountry.dialCode + phoneNumber + let id = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) + authService.navigator.push(.enterVerificationCode( + verificationID: id, + fullPhoneNumber: fullPhoneNumber + )) + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift index 54e41c1fa8..bfb7664412 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift @@ -12,79 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI struct EnterPhoneNumberView: View { - @Environment(AuthService.self) private var authService - @Environment(\.reportError) private var reportError - @State private var phoneNumber: String = "" - @State private var selectedCountry: CountryData = .default - var body: some View { - VStack(spacing: 16) { - Text(authService.string.enterPhoneNumberPlaceholder) - .authFont(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top) - - AuthTextField( - text: $phoneNumber, - label: authService.string.phoneFieldLabel, - prompt: authService.string.enterPhoneNumberPlaceholder, - keyboardType: .phonePad, - contentType: .telephoneNumber, - validations: [ - FormValidators.phoneNumber, - ], - onChange: { _ in } - ) { - CountrySelector( - selectedCountry: $selectedCountry, - enabled: !(authService.authenticationState == .authenticating) - ) - } - - Button(action: { - Task { - do { - let fullPhoneNumber = selectedCountry.dialCode + phoneNumber - let id = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) - authService.navigator.push(.enterVerificationCode( - verificationID: id, - fullPhoneNumber: fullPhoneNumber - )) - } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error - } - } - } - }) { - if authService.authenticationState == .authenticating { - ProgressView() - .frame(height: 32) - .frame(maxWidth: .infinity) - } else { - Text(authService.string.sendCodeButtonLabel) - .frame(height: 32) - .frame(maxWidth: .infinity) - } - } - .authCTAButtonStyle() - .disabled(authService.authenticationState == .authenticating || phoneNumber.isEmpty) - .padding(.top, 8) - - Spacer() - } - .navigationTitle(authService.string.phoneSignInTitle) - .padding(.horizontal) + EnterPhoneNumberScreen { DefaultEnterPhoneNumberContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift new file mode 100644 index 0000000000..0054c44b0d --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift @@ -0,0 +1,163 @@ +// 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 + +/// Seconds between sending a code and allowing a resend. +private let resendDelay: TimeInterval = 60 + +/// The state and actions of the verification code screen, passed to +/// ``EnterVerificationCodeScreen``'s content. +public struct VerificationCodeContentState { + public let verificationCode: Binding + /// Signs in with the code, routing MFA and account conflicts to their handlers. + public let verify: () -> Void + /// Sends a new code. Does nothing while ``resendCountdown`` is above zero. + public let resendCode: () -> Void + /// Pops back to the phone number screen. + public let changeNumber: () -> Void + /// The number the code was sent to, including the dial code. + public let fullPhoneNumber: String + /// Seconds left before ``resendCode`` is available; `0` when it is. + public let resendCountdown: Int + public let isValid: Bool + public let isLoading: Bool + /// The localized message of the last failed attempt, if any. + public let errorMessage: String? +} + +/// The verification code screen, with its content drawn by you. +/// +/// The screen signs in, resends codes, runs the resend countdown, and routes MFA, account +/// conflicts and errors. Your content only draws the UI from the +/// ``VerificationCodeContentState`` it receives. Wrap ``DefaultEnterVerificationCodeContent`` to +/// keep the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case let .enterVerificationCode(verificationID, fullPhoneNumber): +/// EnterVerificationCodeScreen( +/// verificationID: verificationID, +/// fullPhoneNumber: fullPhoneNumber +/// ) { state in +/// TextField("Code", text: state.verificationCode) +/// Button("Verify", action: state.verify) +/// .disabled(!state.isValid || state.isLoading) +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct EnterVerificationCodeScreen: View { + public init(verificationID: String, + fullPhoneNumber: String, + @ViewBuilder content: @escaping (VerificationCodeContentState) -> Content) { + _verificationID = State(initialValue: verificationID) + self.fullPhoneNumber = fullPhoneNumber + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.mfaHandler) private var mfaHandler + @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.reportError) private var reportError + @State private var verificationID: String + @State private var verificationCode: String = "" + @State private var resendAvailableAt = Date().addingTimeInterval(resendDelay) + @State private var resendCountdown = Int(resendDelay) + @State private var isWorking = false + @State private var errorMessage: String? + + private let fullPhoneNumber: String + private let content: (VerificationCodeContentState) -> Content + + public var body: some View { + content(VerificationCodeContentState( + verificationCode: $verificationCode, + verify: verify, + resendCode: resendCode, + changeNumber: { authService.navigator.pop() }, + fullPhoneNumber: fullPhoneNumber, + resendCountdown: resendCountdown, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .task(id: resendAvailableAt) { + repeat { + resendCountdown = max(0, Int(resendAvailableAt.timeIntervalSinceNow.rounded(.up))) + try? await Task.sleep(for: .seconds(1)) + } while resendCountdown > 0 && !Task.isCancelled + } + } + + private var isValid: Bool { + verificationCode.count == 6 + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func verify() { + guard isValid, !isLoading else { return } + Task { + isWorking = true + errorMessage = nil + defer { isWorking = false } + do { + let credential = PhoneAuthProvider.provider() + .credential(withVerificationID: verificationID, verificationCode: verificationCode) + let outcome = try await authService.signIn(credentials: credential) + if case let .mfaRequired(mfaInfo) = outcome, let onMFA = mfaHandler { + onMFA(mfaInfo) + return + } + authService.navigator.clear() + } catch { + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return + } + + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } + + private func resendCode() { + guard resendCountdown == 0, !isLoading else { return } + Task { + isWorking = true + errorMessage = nil + defer { isWorking = false } + do { + verificationID = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) + verificationCode = "" + resendAvailableAt = Date().addingTimeInterval(resendDelay) + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift index 3a644e713c..4924946f3c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift @@ -12,90 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor struct EnterVerificationCodeView: View { - @Environment(AuthService.self) private var authService - @Environment(\.accountConflictHandler) private var accountConflictHandler - @Environment(\.reportError) private var reportError - @State private var verificationCode: String = "" - let verificationID: String let fullPhoneNumber: String var body: some View { - @Bindable var authService = authService - VStack(spacing: 32) { - VStack(spacing: 16) { - VStack(spacing: 8) { - Text(authService.string.sentCodeMessage(phoneNumber: fullPhoneNumber)) - .authFont(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .leading) - - Button { - authService.navigator.pop() - } label: { - Text(authService.string.changeNumberButtonLabel) - .authFont(.caption) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .padding(.bottom) - .frame(maxWidth: .infinity, alignment: .leading) - - VerificationCodeInputField( - code: $verificationCode, - validations: [ - FormValidators.verificationCode, - ], - maintainsValidationMessage: true - ) - - Button(action: { - Task { - do { - try await authService.signInWithPhoneNumber( - verificationID: verificationID, - verificationCode: verificationCode - ) - authService.navigator.clear() - } catch { - if case let AuthServiceError.accountConflict(ctx) = error, - let onConflict = accountConflictHandler { - onConflict(ctx) - return - } - - reportError?(error) - throw error - } - } - }) { - if authService.authenticationState == .authenticating { - ProgressView() - .frame(height: 32) - .frame(maxWidth: .infinity) - } else { - Text(authService.string.verifyAndSignInButtonLabel) - .frame(height: 32) - .frame(maxWidth: .infinity) - } - } - .authCTAButtonStyle() - .disabled(authService.authenticationState == .authenticating || verificationCode.count != 6) - } - - Spacer() - } - .navigationTitle(authService.string.enterVerificationCodeTitle) - .navigationBarTitleDisplayMode(.large) - .padding(.horizontal) + EnterVerificationCodeScreen( + verificationID: verificationID, + fullPhoneNumber: fullPhoneNumber + ) { DefaultEnterVerificationCodeContent(state: $0) } } } From 0477475b28e3ec28be44122eff01e6eea16f817c Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:16:35 +0100 Subject: [PATCH 2/7] fix(auth): tighten phone screen actions and match the picker state API Set the in-flight flag before starting each action's Task, so a quick second tap can't send two codes or overlap verify and resend. Only clear the navigator on .signedIn, so an MFA challenge with no handler doesn't drop the user back to the picker. Type the state closures as @MainActor and give both state structs public initializers, matching AuthMethodPickerState. Clear errorMessage when the input changes, document every field, disable Change number while loading, and show the resend countdown as its own text instead of a concatenated string. --- .../DefaultEnterVerificationCodeContent.swift | 20 ++--- .../Views/EnterPhoneNumberScreen.swift | 43 +++++++++-- .../Views/EnterVerificationCodeScreen.swift | 77 ++++++++++++++----- 3 files changed, 102 insertions(+), 38 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift index 4802e87953..dd145f4e49 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift @@ -40,6 +40,7 @@ public struct DefaultEnterVerificationCodeContent: View { .authFont(.caption) .frame(maxWidth: .infinity, alignment: .leading) } + .disabled(state.isLoading) } .padding(.bottom) .frame(maxWidth: .infinity, alignment: .leading) @@ -67,9 +68,15 @@ public struct DefaultEnterVerificationCodeContent: View { .disabled(state.isLoading || !state.isValid) Button(action: state.resendCode) { - Text(resendLabel) - .authFont(.body) - .frame(maxWidth: .infinity) + HStack(spacing: 4) { + Text(authService.string.resendCodeLabel) + if state.resendCountdown > 0 { + Text(Duration.seconds(state.resendCountdown), format: .time(pattern: .minuteSecond)) + .monospacedDigit() + } + } + .authFont(.body) + .frame(maxWidth: .infinity) } .disabled(state.isLoading || state.resendCountdown > 0) .accessibilityIdentifier("resend-code-button") @@ -81,11 +88,4 @@ public struct DefaultEnterVerificationCodeContent: View { .navigationBarTitleDisplayMode(.large) .padding(.horizontal) } - - private var resendLabel: String { - guard state.resendCountdown > 0 else { return authService.string.resendCodeLabel } - let remaining = Duration.seconds(state.resendCountdown) - .formatted(.time(pattern: .minuteSecond)) - return "\(authService.string.resendCodeLabel) (\(remaining))" - } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift index 5ffc5af9d8..2b8535d3a5 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift @@ -20,14 +20,39 @@ import SwiftUI public struct PhoneNumberContentState { /// The national phone number, without the dial code. public let phoneNumber: Binding + + /// The country whose dial code prefixes ``phoneNumber``. public let selectedCountry: Binding + /// Sends a verification code to the full phone number, then pushes - /// ``AuthView/enterVerificationCode(verificationID:fullPhoneNumber:)``. - public let sendCode: () -> Void + /// ``AuthView/enterVerificationCode(verificationID:fullPhoneNumber:)``. Does nothing unless + /// ``isValid`` is `true` and ``isLoading`` is `false`. + public let sendCode: @MainActor () -> Void + + /// Whether ``phoneNumber`` is non-empty. public let isValid: Bool + + /// Whether a code is being sent, or the ``AuthService`` is signing in. public let isLoading: Bool - /// The localized message of the last failed attempt, if any. + + /// The localized message of the last failed attempt. It clears when the number or country + /// 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(phoneNumber: Binding, + selectedCountry: Binding, + sendCode: @escaping @MainActor () -> Void, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.phoneNumber = phoneNumber + self.selectedCountry = selectedCountry + self.sendCode = sendCode + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } } /// The phone number screen, with its content drawn by you. @@ -62,7 +87,7 @@ public struct EnterPhoneNumberScreen: View { @Environment(\.reportError) private var reportError @State private var phoneNumber: String = "" @State private var selectedCountry: CountryData = .default - @State private var isSending = false + @State private var isWorking = false @State private var errorMessage: String? private let content: (PhoneNumberContentState) -> Content @@ -76,6 +101,8 @@ public struct EnterPhoneNumberScreen: View { isLoading: isLoading, errorMessage: errorMessage )) + .onChange(of: phoneNumber) { errorMessage = nil } + .onChange(of: selectedCountry) { errorMessage = nil } } private var isValid: Bool { @@ -83,15 +110,15 @@ public struct EnterPhoneNumberScreen: View { } private var isLoading: Bool { - isSending || authService.authenticationState == .authenticating + isWorking || authService.authenticationState == .authenticating } private func sendCode() { guard isValid, !isLoading else { return } + isWorking = true + errorMessage = nil Task { - isSending = true - errorMessage = nil - defer { isSending = false } + defer { isWorking = false } do { let fullPhoneNumber = selectedCountry.dialCode + phoneNumber let id = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift index 0054c44b0d..5b681ccc8e 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift @@ -21,29 +21,64 @@ private let resendDelay: TimeInterval = 60 /// The state and actions of the verification code screen, passed to /// ``EnterVerificationCodeScreen``'s content. public struct VerificationCodeContentState { + /// The code the user typed. public let verificationCode: Binding - /// Signs in with the code, routing MFA and account conflicts to their handlers. - public let verify: () -> Void - /// Sends a new code. Does nothing while ``resendCountdown`` is above zero. - public let resendCode: () -> Void + + /// Signs in with the code, routing MFA and account conflicts to their handlers. Does nothing + /// unless ``isValid`` is `true` and ``isLoading`` is `false`. + public let verify: @MainActor () -> Void + + /// Sends a new code and clears ``verificationCode``. Does nothing while ``resendCountdown`` is + /// above zero or ``isLoading`` is `true`. + public let resendCode: @MainActor () -> Void + /// Pops back to the phone number screen. - public let changeNumber: () -> Void + public let changeNumber: @MainActor () -> Void + /// The number the code was sent to, including the dial code. public let fullPhoneNumber: String + /// Seconds left before ``resendCode`` is available; `0` when it is. public let resendCountdown: Int + + /// Whether ``verificationCode`` has 6 digits. public let isValid: Bool + + /// Whether a verify or resend is in flight, or the ``AuthService`` is signing in. public let isLoading: Bool - /// The localized message of the last failed attempt, if any. + + /// The localized message of the last failed verify or resend. It clears when the code 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(verificationCode: Binding, + verify: @escaping @MainActor () -> Void, + resendCode: @escaping @MainActor () -> Void, + changeNumber: @escaping @MainActor () -> Void, + fullPhoneNumber: String, + resendCountdown: Int, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.verificationCode = verificationCode + self.verify = verify + self.resendCode = resendCode + self.changeNumber = changeNumber + self.fullPhoneNumber = fullPhoneNumber + self.resendCountdown = resendCountdown + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } } /// The verification code screen, with its content drawn by you. /// /// The screen signs in, resends codes, runs the resend countdown, and routes MFA, account -/// conflicts and errors. Your content only draws the UI from the -/// ``VerificationCodeContentState`` it receives. Wrap ``DefaultEnterVerificationCodeContent`` to -/// keep the stock markup. +/// conflicts and errors. It reads `verificationID` once; codes it resends replace it +/// internally. Your content only draws the UI from the ``VerificationCodeContentState`` it +/// receives. Wrap ``DefaultEnterVerificationCodeContent`` to keep the stock markup. /// /// Example: /// ```swift @@ -100,11 +135,13 @@ public struct EnterVerificationCodeScreen: View { isLoading: isLoading, errorMessage: errorMessage )) + .onChange(of: verificationCode) { errorMessage = nil } .task(id: resendAvailableAt) { - repeat { + while !Task.isCancelled { resendCountdown = max(0, Int(resendAvailableAt.timeIntervalSinceNow.rounded(.up))) + guard resendCountdown > 0 else { return } try? await Task.sleep(for: .seconds(1)) - } while resendCountdown > 0 && !Task.isCancelled + } } } @@ -118,19 +155,19 @@ public struct EnterVerificationCodeScreen: View { private func verify() { guard isValid, !isLoading else { return } + isWorking = true + errorMessage = nil Task { - isWorking = true - errorMessage = nil defer { isWorking = false } do { let credential = PhoneAuthProvider.provider() .credential(withVerificationID: verificationID, verificationCode: verificationCode) - let outcome = try await authService.signIn(credentials: credential) - if case let .mfaRequired(mfaInfo) = outcome, let onMFA = mfaHandler { - onMFA(mfaInfo) - return + switch try await authService.signIn(credentials: credential) { + case let .mfaRequired(mfaInfo): + mfaHandler?(mfaInfo) + case .signedIn: + authService.navigator.clear() } - authService.navigator.clear() } catch { if case let AuthServiceError.accountConflict(ctx) = error, let onConflict = accountConflictHandler { @@ -146,9 +183,9 @@ public struct EnterVerificationCodeScreen: View { private func resendCode() { guard resendCountdown == 0, !isLoading else { return } + isWorking = true + errorMessage = nil Task { - isWorking = true - errorMessage = nil defer { isWorking = false } do { verificationID = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) From 3faec1ddae0401619e6705e1f2d2b986c1bf34f2 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:44:25 +0100 Subject: [PATCH 3/7] fix(auth): let the resend code button inherit the app font when no theme is set --- .../Sources/Views/DefaultEnterVerificationCodeContent.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift index dd145f4e49..9b724a838d 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift @@ -75,7 +75,7 @@ public struct DefaultEnterVerificationCodeContent: View { .monospacedDigit() } } - .authFont(.body) + .authCustomFont(.body) .frame(maxWidth: .infinity) } .disabled(state.isLoading || state.resendCountdown > 0) From c0f34b077623996cc459a4f6359c1b27e7c9846b Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:08:08 +0100 Subject: [PATCH 4/7] feat(auth): add content slot to MFA enrolment screen Add public MFAEnrolmentScreen, which owns the enrolment state, reauthentication and error reporting and passes its content an MFAEnrollmentContentState. DefaultMFAEnrolmentContent holds the stock markup, and MFAEnrolmentView now renders it through the screen. --- .../Views/DefaultMFAEnrolmentContent.swift | 477 ++++++++++++++ .../Sources/Views/MFAEnrolmentScreen.swift | 374 +++++++++++ .../Sources/Views/MFAEnrolmentView.swift | 623 +----------------- 3 files changed, 852 insertions(+), 622 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAEnrolmentContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentScreen.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAEnrolmentContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAEnrolmentContent.swift new file mode 100644 index 0000000000..bbcb7c1d39 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAEnrolmentContent.swift @@ -0,0 +1,477 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuthUIComponents +import SwiftUI + +private enum FocusableField: Hashable { + case phoneNumber + case verificationCode + case totpCode +} + +/// The stock content of ``MFAEnrolmentScreen``. +@MainActor +public struct DefaultMFAEnrolmentContent: View { + public init(state: MFAEnrollmentContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + @FocusState private var focus: FocusableField? + private let state: MFAEnrollmentContentState + + public var body: some View { + VStack(spacing: 24) { + // Header (only shown when no session is active) + if state.step == .selectFactor { + VStack(spacing: 8) { + Text("Set Up Two-Factor Authentication") + .authFont(.largeTitle) + .fontWeight(.bold) + .multilineTextAlignment(.center) + + Text("Add an extra layer of security to your account") + .authFont(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + + // Factor Type Selection (only if no session started) + if state.step == .selectFactor { + if !state.isMFAEnabled { + VStack(spacing: 12) { + Image(systemName: "lock.slash") + .font(.system(size: 40)) + .foregroundColor(.orange) + + Text("Multi-Factor Authentication Disabled") + .authFont(.title2) + .fontWeight(.semibold) + + Text( + "MFA is not enabled in the current configuration. Please contact your administrator." + ) + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .accessibilityIdentifier("mfa-disabled-message") + } else if state.allowedFactors.isEmpty { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 40)) + .foregroundColor(.orange) + + Text("No Authentication Methods Available") + .authFont(.title2) + .fontWeight(.semibold) + + Text("No MFA methods are configured as allowed. Please contact your administrator.") + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .accessibilityIdentifier("no-factors-message") + } else { + VStack(alignment: .leading, spacing: 12) { + Text("Choose Authentication Method") + .authFont(.headline) + + Picker("Authentication Method", selection: state.selectedFactorType) { + ForEach(state.allowedFactors, id: \.self) { factorType in + switch factorType { + case .sms: + Image(systemName: "message").tag(SecondFactorType.sms) + case .totp: + Image(systemName: "qrcode").tag(SecondFactorType.totp) + } + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("factor-type-picker") + } + } + } + + // Content based on current state + switch state.step { + case .selectFactor: + initialContent + case .enterPhoneNumber, .enterVerificationCode: + smsEnrollmentContent + case .setUpTOTP: + totpEnrollmentContent + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .safeAreaPadding() + .navigationTitle("Two-Factor Authentication") + } + + private var initialContent: some View { + VStack(spacing: 24) { + // Description based on selected type + if state.selectedFactorType.wrappedValue == .sms { + VStack(spacing: 8) { + Image(systemName: "message.circle") + .font(.system(size: 40)) + .foregroundColor(.blue) + + Text("SMS Authentication") + .authFont(.title2) + .fontWeight(.semibold) + + Text("We'll send a verification code to your phone number each time you sign in.") + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } else { + VStack(spacing: 8) { + Image(systemName: "qrcode") + .font(.system(size: 40)) + .foregroundColor(.green) + + Text("Authenticator App") + .authFont(.title2) + .fontWeight(.semibold) + + Text( + "Use an authenticator app like Google Authenticator or Authy to generate verification codes." + ) + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + + Button { + state.startEnrollment() + } label: { + HStack { + if state.isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Get Started") + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .disabled(!state.isValid || state.isLoading) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("start-enrollment-button") + } + } + + private var smsEnrollmentContent: some View { + VStack(spacing: 24) { + // SMS enrollment steps + if state.step == .enterPhoneNumber { + VStack(spacing: 24) { + VStack(spacing: 8) { + Image(systemName: "phone") + .font(.system(size: 48)) + .foregroundColor(.blue) + + Text("Enter Your Phone Number") + .authFont(.title2) + .fontWeight(.semibold) + + Text("We'll send a verification code to this number") + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + + AuthTextField( + text: state.phoneNumber, + label: authService.string.phoneNumberFieldLabel, + prompt: authService.string.enterPhoneNumberPrompt, + keyboardType: .phonePad, + contentType: .telephoneNumber, + validations: [ + FormValidators.phoneNumber, + ], + maintainsValidationMessage: true, + onChange: { _ in } + ) { + CountrySelector( + selectedCountry: state.selectedCountry, + enabled: !state.isLoading + ) + } + .focused($focus, equals: .phoneNumber) + .accessibilityIdentifier("phone-number-field") + + AuthTextField( + text: state.displayName, + label: authService.string.displayNameFieldLabel, + prompt: authService.string.enterDisplayNameForDevicePrompt, + validations: [ + FormValidators.notEmpty(label: "Display name"), + ], + maintainsValidationMessage: true, + leading: { + Image(systemName: "person") + } + ) + .accessibilityIdentifier("display-name-field") + + Button { + state.sendCode() + } label: { + HStack { + if state.isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Send Code") + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .disabled(!state.isValid || state.isLoading) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("send-sms-button") + } + } else if state.step == .enterVerificationCode { + VStack(spacing: 24) { + VStack(spacing: 8) { + Image(systemName: "checkmark.message") + .font(.system(size: 48)) + .foregroundColor(.green) + + Text("Enter Verification Code") + .authFont(.title2) + .fontWeight(.semibold) + + Text("We sent a code to \(state.sentPhoneNumber ?? "your phone")") + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + + VerificationCodeInputField( + code: state.verificationCode, + validations: [ + FormValidators.verificationCode, + ], + maintainsValidationMessage: true + ) + .accessibilityIdentifier("verification-code-field") + + Button { + state.enroll() + } label: { + HStack { + if state.isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Complete Setup") + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .disabled(!state.isValid || state.isLoading) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("complete-enrollment-button") + + Button { + state.sendCode() + } label: { + Text("Resend Code") + .authCustomFont(.body) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("resend-code-button") + } + } + } + } + + private var totpEnrollmentContent: some View { + VStack(spacing: 16) { + if let totpSecret = state.totpSecret { + VStack(spacing: 16) { + VStack(spacing: 6) { + Image(systemName: "qrcode") + .font(.system(size: 40)) + .foregroundColor(.green) + + Text("Scan QR Code") + .authFont(.title2) + .fontWeight(.semibold) + + Text("Scan with your authenticator app or tap to open directly") + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .lineLimit(nil) + .padding(.horizontal) + } + + // QR Code generated from the otpauth:// URI + if let qrURL = state.totpQRCodeURL, + let qrImage = generateQRCode(from: qrURL.absoluteString) { + Button(action: { + UIApplication.shared.open(qrURL) + }) { + VStack(spacing: 8) { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 180, height: 180) + .accessibilityIdentifier("qr-code-image") + + HStack(spacing: 6) { + Image(systemName: "arrow.up.forward.app.fill") + .authFont(.caption) + Text("Tap to open in authenticator app") + .authFont(.caption) + .fontWeight(.medium) + } + .foregroundColor(.blue) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("open-authenticator-button") + } else { + RoundedRectangle(cornerRadius: 8) + .fill(Color.gray.opacity(0.3)) + .frame(width: 180, height: 180) + .overlay( + VStack { + Image(systemName: "exclamationmark.triangle") + .authFont(.title) + .foregroundColor(.orange) + Text("Unable to generate QR Code") + .authFont(.caption) + } + ) + } + + VStack(spacing: 6) { + Text("Manual Entry Key:") + .authFont(.headline) + + Button(action: state.copySecret) { + HStack { + Text(totpSecret) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + .minimumScaleFactor(0.5) + + Spacer() + + Image(systemName: state.didCopySecret ? "checkmark" : "doc.on.doc") + .foregroundColor(state.didCopySecret ? .green : .blue) + } + .padding() + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + .buttonStyle(.plain) + .accessibilityIdentifier("totp-secret-key") + + if state.didCopySecret { + Text("Copied to clipboard!") + .authFont(.caption) + .foregroundColor(.green) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.2), value: state.didCopySecret) + + AuthTextField( + text: state.displayName, + label: authService.string.displayNameFieldLabel, + prompt: authService.string.enterDisplayNameForAuthenticatorPrompt, + validations: [ + FormValidators.notEmpty(label: "Display name"), + ], + maintainsValidationMessage: true, + leading: { + Image(systemName: "person") + } + ) + .accessibilityIdentifier("display-name-field") + + VerificationCodeInputField( + code: state.totpCode, + validations: [ + FormValidators.verificationCode, + ], + maintainsValidationMessage: true + ) + .accessibilityIdentifier("totp-code-field") + + Button { + state.enroll() + } label: { + HStack { + if state.isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Complete Setup") + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .disabled(!state.isValid || state.isLoading) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("complete-enrollment-button") + } + } + } + } + + private func generateQRCode(from string: String) -> UIImage? { + let data = Data(string.utf8) + + guard let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil } + filter.setValue(data, forKey: "inputMessage") + filter.setValue("H", forKey: "inputCorrectionLevel") + + guard let ciImage = filter.outputImage else { return nil } + + // Scale up the QR code for better quality + let transform = CGAffineTransform(scaleX: 10, y: 10) + let scaledImage = ciImage.transformed(by: transform) + + let context = CIContext() + guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { + return nil + } + + return UIImage(cgImage: cgImage) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentScreen.swift new file mode 100644 index 0000000000..69ca4d28e9 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentScreen.swift @@ -0,0 +1,374 @@ +// 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 FirebaseAuthUIComponents +import SwiftUI + +/// The state and actions of the MFA enrolment screen, passed to ``MFAEnrolmentScreen``'s content. +public struct MFAEnrollmentContentState { + /// A step of the enrolment flow. + public enum Step { + /// Choosing a factor, before ``MFAEnrollmentContentState/startEnrollment`` succeeds. + case selectFactor + /// Entering the phone number and display name for an SMS factor. + case enterPhoneNumber + /// Entering the code sent to ``MFAEnrollmentContentState/sentPhoneNumber``. + case enterVerificationCode + /// Adding ``MFAEnrollmentContentState/totpSecret`` to an authenticator app, then entering + /// the display name and the code it generates. + case setUpTOTP + } + + /// The current step. + public let step: Step + + /// The factor ``startEnrollment`` sets up. The screen resets it to the first of + /// ``allowedFactors`` when it is not one of them. + public let selectedFactorType: Binding + + /// The national phone number of an SMS factor, without the dial code. + public let phoneNumber: Binding + + /// The country whose dial code prefixes ``phoneNumber``. + public let selectedCountry: Binding + + /// The code sent by SMS. + public let verificationCode: Binding + + /// The code generated by the authenticator app. + public let totpCode: Binding + + /// The name the factor is listed under. + public let displayName: Binding + + /// Starts enrolling ``selectedFactorType``, moving to ``Step/enterPhoneNumber`` or + /// ``Step/setUpTOTP``. Does nothing unless ``step`` is ``Step/selectFactor``, ``isValid`` is + /// `true` and ``isLoading`` is `false`. + public let startEnrollment: @MainActor () -> Void + + /// Sends a code to the full phone number, moving to ``Step/enterVerificationCode``, or sends a + /// new one from that step. Does nothing unless an SMS enrolment has started, ``phoneNumber`` + /// and ``displayName`` are non-empty, and ``isLoading`` is `false`. + public let sendCode: @MainActor () -> Void + + /// Enrols the factor with the SMS or authenticator code, reauthenticating first when Firebase + /// requires it, then clears navigation. Does nothing unless ``step`` is + /// ``Step/enterVerificationCode`` or ``Step/setUpTOTP``, ``isValid`` is `true` and + /// ``isLoading`` is `false`. + public let enroll: @MainActor () -> Void + + /// Copies ``totpSecret`` to the pasteboard and sets ``didCopySecret`` briefly. + public let copySecret: @MainActor () -> Void + + /// Whether MFA is enabled in the ``AuthConfiguration``. + public let isMFAEnabled: Bool + + /// The factors the ``AuthConfiguration`` allows, SMS first. + public let allowedFactors: [SecondFactorType] + + /// The number the code was sent to, including the dial code, from + /// ``Step/enterVerificationCode``. + public let sentPhoneNumber: String? + + /// The shared secret to enter in an authenticator app, from ``Step/setUpTOTP``. + public let totpSecret: String? + + /// The `otpauth://` URL to show as a QR code or open in an authenticator app, from + /// ``Step/setUpTOTP``. + public let totpQRCodeURL: URL? + + /// Whether ``copySecret`` just copied the secret. + public let didCopySecret: Bool + + /// Whether the current step's input is complete: MFA is enabled with an allowed factor in + /// ``Step/selectFactor``, ``phoneNumber`` and ``displayName`` are non-empty in + /// ``Step/enterPhoneNumber``, and ``displayName`` plus the step's code are non-empty in + /// ``Step/enterVerificationCode`` and ``Step/setUpTOTP``. + public let isValid: Bool + + /// Whether an enrolment action is in flight, or the ``AuthService`` is signing in. + public let isLoading: Bool + + /// The localized message of the last failed action. It clears when any input 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(step: Step, + selectedFactorType: Binding, + phoneNumber: Binding, + selectedCountry: Binding, + verificationCode: Binding, + totpCode: Binding, + displayName: Binding, + startEnrollment: @escaping @MainActor () -> Void, + sendCode: @escaping @MainActor () -> Void, + enroll: @escaping @MainActor () -> Void, + copySecret: @escaping @MainActor () -> Void, + isMFAEnabled: Bool, + allowedFactors: [SecondFactorType], + sentPhoneNumber: String?, + totpSecret: String?, + totpQRCodeURL: URL?, + didCopySecret: Bool, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.step = step + self.selectedFactorType = selectedFactorType + self.phoneNumber = phoneNumber + self.selectedCountry = selectedCountry + self.verificationCode = verificationCode + self.totpCode = totpCode + self.displayName = displayName + self.startEnrollment = startEnrollment + self.sendCode = sendCode + self.enroll = enroll + self.copySecret = copySecret + self.isMFAEnabled = isMFAEnabled + self.allowedFactors = allowedFactors + self.sentPhoneNumber = sentPhoneNumber + self.totpSecret = totpSecret + self.totpQRCodeURL = totpQRCodeURL + self.didCopySecret = didCopySecret + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The MFA enrolment screen, with its content drawn by you. +/// +/// The screen starts the enrolment, sends SMS codes, enrols the factor, presents +/// reauthentication when Firebase requires it, and reports errors. Your content only draws the +/// UI for the ``MFAEnrollmentContentState/step`` it receives. Wrap ``DefaultMFAEnrolmentContent`` +/// to keep the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case .mfaEnrollment: +/// MFAEnrolmentScreen { state in +/// switch state.step { +/// case .enterPhoneNumber: +/// TextField("Phone number", text: state.phoneNumber) +/// TextField("Device name", text: state.displayName) +/// Button("Send code", action: state.sendCode) +/// .disabled(!state.isValid || state.isLoading) +/// default: +/// DefaultMFAEnrolmentContent(state: state) +/// } +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct MFAEnrolmentScreen: View { + public init(@ViewBuilder content: @escaping (MFAEnrollmentContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var selectedFactorType: SecondFactorType = .sms + @State private var phoneNumber = "" + @State private var selectedCountry: CountryData = .default + @State private var verificationCode = "" + @State private var totpCode = "" + @State private var displayName = "" + @State private var currentSession: EnrollmentSession? + @State private var didCopySecret = false + @State private var isWorking = false + @State private var errorMessage: String? + @State private var reauthCoordinator = ReauthenticationCoordinator() + + private let content: (MFAEnrollmentContentState) -> Content + + public var body: some View { + content(MFAEnrollmentContentState( + step: step, + selectedFactorType: $selectedFactorType, + phoneNumber: $phoneNumber, + selectedCountry: $selectedCountry, + verificationCode: $verificationCode, + totpCode: $totpCode, + displayName: $displayName, + startEnrollment: startEnrollment, + sendCode: sendCode, + enroll: enroll, + copySecret: copySecret, + isMFAEnabled: authService.configuration.mfaEnabled, + allowedFactors: allowedFactors, + sentPhoneNumber: currentSession?.phoneNumber, + totpSecret: currentSession?.totpInfo?.sharedSecretKey, + totpQRCodeURL: currentSession?.totpInfo?.qrCodeURL, + didCopySecret: didCopySecret, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .withReauthentication(coordinator: reauthCoordinator) + .onAppear { + if !allowedFactors.contains(selectedFactorType), + let firstAllowed = allowedFactors.first { + selectedFactorType = firstAllowed + } + } + .onChange(of: selectedFactorType) { errorMessage = nil } + .onChange(of: phoneNumber) { errorMessage = nil } + .onChange(of: selectedCountry) { errorMessage = nil } + .onChange(of: verificationCode) { errorMessage = nil } + .onChange(of: totpCode) { errorMessage = nil } + .onChange(of: displayName) { errorMessage = nil } + } + + private var allowedFactors: [SecondFactorType] { + Array(authService.configuration.allowedSecondFactors).sorted { lhs, rhs in + lhs == .sms && rhs == .totp + } + } + + private var step: MFAEnrollmentContentState.Step { + guard let session = currentSession else { return .selectFactor } + switch session.type { + case .sms: + return session.status == .verificationSent ? .enterVerificationCode : .enterPhoneNumber + case .totp: + return .setUpTOTP + } + } + + private var isValid: Bool { + switch step { + case .selectFactor: + authService.configuration.mfaEnabled && !allowedFactors.isEmpty + case .enterPhoneNumber: + !phoneNumber.isEmpty && !displayName.isEmpty + case .enterVerificationCode: + !verificationCode.isEmpty && !displayName.isEmpty + case .setUpTOTP: + !totpCode.isEmpty && !displayName.isEmpty + } + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func startEnrollment() { + guard step == .selectFactor, isValid, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + currentSession = try await authService.startMfaEnrollment( + type: selectedFactorType, + accountName: authService.currentUser?.email, + issuer: authService.configuration.mfaIssuer + ) + } catch { + fail(error) + } + } + } + + private func sendCode() { + guard let session = currentSession, session.type == .sms, + !phoneNumber.isEmpty, !displayName.isEmpty, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + let fullPhoneNumber = selectedCountry.dialCode + phoneNumber + let verificationId = try await authService.sendSmsVerificationForEnrollment( + session: session, + phoneNumber: fullPhoneNumber + ) + currentSession = EnrollmentSession( + id: session.id, + type: session.type, + session: session.session, + totpInfo: session.totpInfo, + phoneNumber: fullPhoneNumber, + verificationId: verificationId, + status: .verificationSent, + createdAt: session.createdAt, + expiresAt: session.expiresAt + ) + } catch { + fail(error) + } + } + } + + private func enroll() { + guard let session = currentSession, + step == .enterVerificationCode || step == .setUpTOTP, + isValid, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + let code = session.type == .sms ? verificationCode : totpCode + try await withReauthenticationIfNeeded( + authService: authService, + coordinator: reauthCoordinator + ) { + try await authService.completeEnrollment( + session: session, + verificationId: session.verificationId, + verificationCode: code, + displayName: displayName + ) + } + resetForm() + authService.navigator.clear() + } catch { + fail(error) + } + } + } + + private func copySecret() { + guard let secret = currentSession?.totpInfo?.sharedSecretKey else { return } + UIPasteboard.general.string = secret + didCopySecret = true + Task { + try? await Task.sleep(nanoseconds: 500_000_000) + didCopySecret = false + } + } + + private func fail(_ error: Error) { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + + private func resetForm() { + currentSession = nil + phoneNumber = "" + selectedCountry = .default + verificationCode = "" + totpCode = "" + displayName = "" + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift index 7405ab1db1..cf61d3885c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift @@ -12,638 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI -private enum FocusableField: Hashable { - case phoneNumber - case verificationCode - case totpCode -} - @MainActor public struct MFAEnrolmentView { - @Environment(AuthService.self) private var authService - @Environment(\.reportError) private var reportError - - @State private var selectedFactorType: SecondFactorType = .sms - @State private var phoneNumber = "" - @State private var selectedCountry: CountryData = .default - @State private var verificationCode = "" - @State private var totpCode = "" - @State private var currentSession: EnrollmentSession? - @State private var isLoading = false - @State private var displayName = "" - @State private var showCopiedFeedback = false - @State private var reauthCoordinator = ReauthenticationCoordinator() - - @FocusState private var focus: FocusableField? - public init() {} - - private var allowedFactorTypes: [SecondFactorType] { - return Array(authService.configuration.allowedSecondFactors).sorted { lhs, rhs in - // Sort SMS first, then TOTP - switch (lhs, rhs) { - case (.sms, .totp): return true - case (.totp, .sms): return false - default: return false - } - } - } - - private var canStartEnrollment: Bool { - !isLoading && currentSession == nil && authService.configuration.mfaEnabled - } - - private var canSendSMSVerification: Bool { - currentSession?.type == .sms && - currentSession?.status == .initiated && - !phoneNumber.isEmpty && - !displayName.isEmpty && - !isLoading - } - - private var canCompleteEnrollment: Bool { - guard let session = currentSession, !isLoading else { return false } - - switch session.type { - case .sms: - return session.status == .verificationSent && !verificationCode.isEmpty && !displayName - .isEmpty - case .totp: - return session.status == .initiated && !totpCode.isEmpty && !displayName.isEmpty - } - } - - private func startEnrollment() { - Task { - isLoading = true - defer { isLoading = false } - - do { - let session = try await authService.startMfaEnrollment( - type: selectedFactorType, - accountName: authService.currentUser?.email, - issuer: authService.configuration.mfaIssuer - ) - currentSession = session - } catch { - reportError?(error) - } - } - } - - private func sendSMSVerification() { - guard let session = currentSession else { return } - - Task { - isLoading = true - defer { isLoading = false } - - do { - let fullPhoneNumber = selectedCountry.dialCode + phoneNumber - let verificationId = try await authService.sendSmsVerificationForEnrollment( - session: session, - phoneNumber: fullPhoneNumber - ) - // Update session status - currentSession = EnrollmentSession( - id: session.id, - type: session.type, - session: session.session, - totpInfo: session.totpInfo, - phoneNumber: fullPhoneNumber, - verificationId: verificationId, - status: .verificationSent, - createdAt: session.createdAt, - expiresAt: session.expiresAt - ) - } catch { - reportError?(error) - } - } - } - - private func completeEnrollment() { - guard let session = currentSession else { return } - - Task { - isLoading = true - defer { isLoading = false } - - do { - let code = session.type == .sms ? verificationCode : totpCode - try await withReauthenticationIfNeeded( - authService: authService, - coordinator: reauthCoordinator - ) { - try await authService.completeEnrollment( - session: session, - verificationId: session.verificationId, - verificationCode: code, - displayName: displayName - ) - } - - // Reset form state on success - resetForm() - - authService.navigator.clear() - } catch { - reportError?(error) - } - } - } - - private func resetForm() { - currentSession = nil - phoneNumber = "" - selectedCountry = .default - verificationCode = "" - totpCode = "" - displayName = "" - focus = nil - } - - private func copyToClipboard(_ text: String) { - UIPasteboard.general.string = text - - // Show feedback - showCopiedFeedback = true - - // Quickly show it has been copied to the clipboard - Task { - try? await Task.sleep(nanoseconds: 500_000_000) - showCopiedFeedback = false - } - } - - private func generateQRCode(from string: String) -> UIImage? { - let data = Data(string.utf8) - - guard let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil } - filter.setValue(data, forKey: "inputMessage") - filter.setValue("H", forKey: "inputCorrectionLevel") - - guard let ciImage = filter.outputImage else { return nil } - - // Scale up the QR code for better quality - let transform = CGAffineTransform(scaleX: 10, y: 10) - let scaledImage = ciImage.transformed(by: transform) - - let context = CIContext() - guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { - return nil - } - - return UIImage(cgImage: cgImage) - } } extension MFAEnrolmentView: View { public var body: some View { - VStack(spacing: 24) { - // Header (only shown when no session is active) - if currentSession == nil { - VStack(spacing: 8) { - Text("Set Up Two-Factor Authentication") - .authFont(.largeTitle) - .fontWeight(.bold) - .multilineTextAlignment(.center) - - Text("Add an extra layer of security to your account") - .authFont(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - } - - // Factor Type Selection (only if no session started) - if currentSession == nil { - if !authService.configuration.mfaEnabled { - VStack(spacing: 12) { - Image(systemName: "lock.slash") - .font(.system(size: 40)) - .foregroundColor(.orange) - - Text("Multi-Factor Authentication Disabled") - .authFont(.title2) - .fontWeight(.semibold) - - Text( - "MFA is not enabled in the current configuration. Please contact your administrator." - ) - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .accessibilityIdentifier("mfa-disabled-message") - } else if allowedFactorTypes.isEmpty { - VStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 40)) - .foregroundColor(.orange) - - Text("No Authentication Methods Available") - .authFont(.title2) - .fontWeight(.semibold) - - Text("No MFA methods are configured as allowed. Please contact your administrator.") - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .accessibilityIdentifier("no-factors-message") - } else { - VStack(alignment: .leading, spacing: 12) { - Text("Choose Authentication Method") - .authFont(.headline) - - Picker("Authentication Method", selection: $selectedFactorType) { - ForEach(allowedFactorTypes, id: \.self) { factorType in - switch factorType { - case .sms: - Image(systemName: "message").tag(SecondFactorType.sms) - case .totp: - Image(systemName: "qrcode").tag(SecondFactorType.totp) - } - } - } - .pickerStyle(.segmented) - .accessibilityIdentifier("factor-type-picker") - } - } - } - - // Content based on current state - if let session = currentSession { - enrollmentContent(for: session) - } else { - initialContent - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .safeAreaPadding() - .withReauthentication(coordinator: reauthCoordinator) - .navigationTitle("Two-Factor Authentication") - .onAppear { - // Initialize selected factor type to first allowed type - if !allowedFactorTypes.contains(selectedFactorType), - let firstAllowed = allowedFactorTypes.first { - selectedFactorType = firstAllowed - } - } - } - - @ViewBuilder - private var initialContent: some View { - VStack(spacing: 24) { - // Description based on selected type - if selectedFactorType == .sms { - VStack(spacing: 8) { - Image(systemName: "message.circle") - .font(.system(size: 40)) - .foregroundColor(.blue) - - Text("SMS Authentication") - .authFont(.title2) - .fontWeight(.semibold) - - Text("We'll send a verification code to your phone number each time you sign in.") - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - } else { - VStack(spacing: 8) { - Image(systemName: "qrcode") - .font(.system(size: 40)) - .foregroundColor(.green) - - Text("Authenticator App") - .authFont(.title2) - .fontWeight(.semibold) - - Text( - "Use an authenticator app like Google Authenticator or Authy to generate verification codes." - ) - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - } - - Button { - startEnrollment() - } label: { - HStack { - if isLoading { - ProgressView() - .scaleEffect(0.8) - } - Text("Get Started") - } - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .disabled(!canStartEnrollment) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("start-enrollment-button") - } - } - - @ViewBuilder - private func enrollmentContent(for session: EnrollmentSession) -> some View { - switch session.type { - case .sms: - smsEnrollmentContent(session: session) - case .totp: - totpEnrollmentContent(session: session) - } - } - - @ViewBuilder - private func smsEnrollmentContent(session: EnrollmentSession) -> some View { - VStack(spacing: 24) { - // SMS enrollment steps - if session.status == .initiated { - VStack(spacing: 24) { - VStack(spacing: 8) { - Image(systemName: "phone") - .font(.system(size: 48)) - .foregroundColor(.blue) - - Text("Enter Your Phone Number") - .authFont(.title2) - .fontWeight(.semibold) - - Text("We'll send a verification code to this number") - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - - AuthTextField( - text: $phoneNumber, - label: authService.string.phoneNumberFieldLabel, - prompt: authService.string.enterPhoneNumberPrompt, - keyboardType: .phonePad, - contentType: .telephoneNumber, - validations: [ - FormValidators.phoneNumber, - ], - maintainsValidationMessage: true, - onChange: { _ in } - ) { - CountrySelector( - selectedCountry: $selectedCountry, - enabled: !isLoading - ) - } - .focused($focus, equals: .phoneNumber) - .accessibilityIdentifier("phone-number-field") - - AuthTextField( - text: $displayName, - label: authService.string.displayNameFieldLabel, - prompt: authService.string.enterDisplayNameForDevicePrompt, - validations: [ - FormValidators.notEmpty(label: "Display name"), - ], - maintainsValidationMessage: true, - leading: { - Image(systemName: "person") - } - ) - .accessibilityIdentifier("display-name-field") - - Button { - sendSMSVerification() - } label: { - HStack { - if isLoading { - ProgressView() - .scaleEffect(0.8) - } - Text("Send Code") - } - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .disabled(!canSendSMSVerification) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("send-sms-button") - } - } else if session.status == .verificationSent { - VStack(spacing: 24) { - VStack(spacing: 8) { - Image(systemName: "checkmark.message") - .font(.system(size: 48)) - .foregroundColor(.green) - - Text("Enter Verification Code") - .authFont(.title2) - .fontWeight(.semibold) - - Text("We sent a code to \(session.phoneNumber ?? "your phone")") - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - - VerificationCodeInputField( - code: $verificationCode, - validations: [ - FormValidators.verificationCode, - ], - maintainsValidationMessage: true - ) - .accessibilityIdentifier("verification-code-field") - - Button { - completeEnrollment() - } label: { - HStack { - if isLoading { - ProgressView() - .scaleEffect(0.8) - } - Text("Complete Setup") - } - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .disabled(!canCompleteEnrollment) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("complete-enrollment-button") - - Button { - sendSMSVerification() - } label: { - Text("Resend Code") - .authCustomFont(.body) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("resend-code-button") - } - } - } - } - - @ViewBuilder - private func totpEnrollmentContent(session: EnrollmentSession) -> some View { - VStack(spacing: 16) { - if let totpInfo = session.totpInfo { - VStack(spacing: 16) { - VStack(spacing: 6) { - Image(systemName: "qrcode") - .font(.system(size: 40)) - .foregroundColor(.green) - - Text("Scan QR Code") - .authFont(.title2) - .fontWeight(.semibold) - - Text("Scan with your authenticator app or tap to open directly") - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .lineLimit(nil) - .padding(.horizontal) - } - - // QR Code generated from the otpauth:// URI - if let qrURL = totpInfo.qrCodeURL, - let qrImage = generateQRCode(from: qrURL.absoluteString) { - Button(action: { - UIApplication.shared.open(qrURL) - }) { - VStack(spacing: 8) { - Image(uiImage: qrImage) - .interpolation(.none) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 180, height: 180) - .accessibilityIdentifier("qr-code-image") - - HStack(spacing: 6) { - Image(systemName: "arrow.up.forward.app.fill") - .authFont(.caption) - Text("Tap to open in authenticator app") - .authFont(.caption) - .fontWeight(.medium) - } - .foregroundColor(.blue) - } - } - .buttonStyle(.plain) - .accessibilityIdentifier("open-authenticator-button") - } else { - RoundedRectangle(cornerRadius: 8) - .fill(Color.gray.opacity(0.3)) - .frame(width: 180, height: 180) - .overlay( - VStack { - Image(systemName: "exclamationmark.triangle") - .authFont(.title) - .foregroundColor(.orange) - Text("Unable to generate QR Code") - .authFont(.caption) - } - ) - } - - VStack(spacing: 6) { - Text("Manual Entry Key:") - .authFont(.headline) - - Button(action: { - copyToClipboard(totpInfo.sharedSecretKey) - }) { - HStack { - Text(totpInfo.sharedSecretKey) - .font(.system(.body, design: .monospaced)) - .lineLimit(1) - .minimumScaleFactor(0.5) - - Spacer() - - Image(systemName: showCopiedFeedback ? "checkmark" : "doc.on.doc") - .foregroundColor(showCopiedFeedback ? .green : .blue) - } - .padding() - .background(Color.gray.opacity(0.1)) - .cornerRadius(8) - } - .buttonStyle(.plain) - .accessibilityIdentifier("totp-secret-key") - - if showCopiedFeedback { - Text("Copied to clipboard!") - .authFont(.caption) - .foregroundColor(.green) - .transition(.opacity) - } - } - .animation(.easeInOut(duration: 0.2), value: showCopiedFeedback) - - AuthTextField( - text: $displayName, - label: authService.string.displayNameFieldLabel, - prompt: authService.string.enterDisplayNameForAuthenticatorPrompt, - validations: [ - FormValidators.notEmpty(label: "Display name"), - ], - maintainsValidationMessage: true, - leading: { - Image(systemName: "person") - } - ) - .accessibilityIdentifier("display-name-field") - - VerificationCodeInputField( - code: $totpCode, - validations: [ - FormValidators.verificationCode, - ], - maintainsValidationMessage: true - ) - .accessibilityIdentifier("totp-code-field") - - Button { - completeEnrollment() - } label: { - HStack { - if isLoading { - ProgressView() - .scaleEffect(0.8) - } - Text("Complete Setup") - } - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .disabled(!canCompleteEnrollment) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("complete-enrollment-button") - } - } - } + MFAEnrolmentScreen { DefaultMFAEnrolmentContent(state: $0) } } } From 803b881d4e2571023b3b4fecfebae19691af0d1c Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:30:21 +0100 Subject: [PATCH 5/7] fix(auth): tighten MFA enrolment steps and factor validation Map finished enrolment sessions back to factor selection, require the selected factor to be allowed before starting, keep copy feedback from being cut short, clear focus between steps, and cover the step and isValid rules with unit tests. --- .../Views/DefaultMFAEnrolmentContent.swift | 1 + .../Sources/Views/MFAEnrolmentScreen.swift | 96 +++++++++++++------ .../MFAEnrolmentScreenTests.swift | 94 ++++++++++++++++++ 3 files changed, 164 insertions(+), 27 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentScreenTests.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAEnrolmentContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAEnrolmentContent.swift index bbcb7c1d39..7bea3ef340 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAEnrolmentContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAEnrolmentContent.swift @@ -119,6 +119,7 @@ public struct DefaultMFAEnrolmentContent: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .safeAreaPadding() .navigationTitle("Two-Factor Authentication") + .onChange(of: state.step) { focus = nil } } private var initialContent: some View { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentScreen.swift index 69ca4d28e9..0c8ef10efd 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentScreen.swift @@ -19,8 +19,11 @@ import SwiftUI /// The state and actions of the MFA enrolment screen, passed to ``MFAEnrolmentScreen``'s content. public struct MFAEnrollmentContentState { /// A step of the enrolment flow. + /// + /// Later releases may add steps, so give a `switch` over it a `default` case. public enum Step { - /// Choosing a factor, before ``MFAEnrollmentContentState/startEnrollment`` succeeds. + /// Choosing a factor, before ``MFAEnrollmentContentState/startEnrollment`` succeeds or after + /// an enrolment session completed, failed or expired. case selectFactor /// Entering the phone number and display name for an SMS factor. case enterPhoneNumber @@ -34,8 +37,8 @@ public struct MFAEnrollmentContentState { /// The current step. public let step: Step - /// The factor ``startEnrollment`` sets up. The screen resets it to the first of - /// ``allowedFactors`` when it is not one of them. + /// The factor ``startEnrollment`` sets up. When the screen appears, it sets it to the first of + /// ``allowedFactors`` if it is not one of them. public let selectedFactorType: Binding /// The national phone number of an SMS factor, without the dial code. @@ -92,10 +95,10 @@ public struct MFAEnrollmentContentState { /// Whether ``copySecret`` just copied the secret. public let didCopySecret: Bool - /// Whether the current step's input is complete: MFA is enabled with an allowed factor in - /// ``Step/selectFactor``, ``phoneNumber`` and ``displayName`` are non-empty in - /// ``Step/enterPhoneNumber``, and ``displayName`` plus the step's code are non-empty in - /// ``Step/enterVerificationCode`` and ``Step/setUpTOTP``. + /// Whether the current step's input is complete: MFA is enabled and ``selectedFactorType`` is + /// one of ``allowedFactors`` in ``Step/selectFactor``, ``phoneNumber`` and ``displayName`` are + /// non-empty in ``Step/enterPhoneNumber``, and ``displayName`` plus the step's code are + /// non-empty in ``Step/enterVerificationCode`` and ``Step/setUpTOTP``. public let isValid: Bool /// Whether an enrolment action is in flight, or the ``AuthService`` is signing in. @@ -194,6 +197,7 @@ public struct MFAEnrolmentScreen: View { @State private var displayName = "" @State private var currentSession: EnrollmentSession? @State private var didCopySecret = false + @State private var copyFeedback: Task? @State private var isWorking = false @State private var errorMessage: String? @State private var reauthCoordinator = ReauthenticationCoordinator() @@ -239,32 +243,24 @@ public struct MFAEnrolmentScreen: View { } private var allowedFactors: [SecondFactorType] { - Array(authService.configuration.allowedSecondFactors).sorted { lhs, rhs in - lhs == .sms && rhs == .totp - } + MFAEnrollmentContentState.sortedFactors(authService.configuration.allowedSecondFactors) } private var step: MFAEnrollmentContentState.Step { guard let session = currentSession else { return .selectFactor } - switch session.type { - case .sms: - return session.status == .verificationSent ? .enterVerificationCode : .enterPhoneNumber - case .totp: - return .setUpTOTP - } + return MFAEnrollmentContentState.Step(type: session.type, status: session.status) } private var isValid: Bool { - switch step { - case .selectFactor: - authService.configuration.mfaEnabled && !allowedFactors.isEmpty - case .enterPhoneNumber: - !phoneNumber.isEmpty && !displayName.isEmpty - case .enterVerificationCode: - !verificationCode.isEmpty && !displayName.isEmpty - case .setUpTOTP: - !totpCode.isEmpty && !displayName.isEmpty - } + step.isValid( + isMFAEnabled: authService.configuration.mfaEnabled, + allowedFactors: allowedFactors, + selectedFactorType: selectedFactorType, + phoneNumber: phoneNumber, + verificationCode: verificationCode, + totpCode: totpCode, + displayName: displayName + ) } private var isLoading: Bool { @@ -352,8 +348,10 @@ public struct MFAEnrolmentScreen: View { guard let secret = currentSession?.totpInfo?.sharedSecretKey else { return } UIPasteboard.general.string = secret didCopySecret = true - Task { + copyFeedback?.cancel() + copyFeedback = Task { try? await Task.sleep(nanoseconds: 500_000_000) + guard !Task.isCancelled else { return } didCopySecret = false } } @@ -372,3 +370,47 @@ public struct MFAEnrolmentScreen: View { displayName = "" } } + +extension MFAEnrollmentContentState { + /// The factors in display order, SMS first. + static func sortedFactors(_ factors: Set) -> [SecondFactorType] { + factors.sorted { lhs, rhs in lhs == .sms && rhs == .totp } + } +} + +extension MFAEnrollmentContentState.Step { + /// The step for a started enrolment session. Sessions that completed, failed or expired go back + /// to ``selectFactor`` so the user can start again. + init(type: SecondFactorType, status: EnrollmentSession.EnrollmentStatus) { + switch (type, status) { + case (_, .completed), (_, .failed), (_, .expired): + self = .selectFactor + case (.sms, .initiated): + self = .enterPhoneNumber + case (.sms, .verificationSent), (.sms, .verificationPending): + self = .enterVerificationCode + case (.totp, _): + self = .setUpTOTP + } + } + + /// Whether the input this step needs is complete. + func isValid(isMFAEnabled: Bool, + allowedFactors: [SecondFactorType], + selectedFactorType: SecondFactorType, + phoneNumber: String, + verificationCode: String, + totpCode: String, + displayName: String) -> Bool { + switch self { + case .selectFactor: + isMFAEnabled && allowedFactors.contains(selectedFactorType) + case .enterPhoneNumber: + !phoneNumber.isEmpty && !displayName.isEmpty + case .enterVerificationCode: + !verificationCode.isEmpty && !displayName.isEmpty + case .setUpTOTP: + !totpCode.isEmpty && !displayName.isEmpty + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentScreenTests.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentScreenTests.swift new file mode 100644 index 0000000000..a84d1390a5 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentScreenTests.swift @@ -0,0 +1,94 @@ +// 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 Testing + +private typealias Step = MFAEnrollmentContentState.Step + +@Suite("MFAEnrolmentScreen Tests") +struct MFAEnrolmentScreenTests { + @Test("Allowed factors list SMS first") + func sortedFactorsListSMSFirst() { + #expect(MFAEnrollmentContentState.sortedFactors([.totp, .sms]) == [.sms, .totp]) + #expect(MFAEnrollmentContentState.sortedFactors([.totp]) == [.totp]) + #expect(MFAEnrollmentContentState.sortedFactors([]).isEmpty) + } + + @Test("SMS sessions map to the phone number and code steps") + func smsSessionSteps() { + #expect(Step(type: .sms, status: .initiated) == .enterPhoneNumber) + #expect(Step(type: .sms, status: .verificationSent) == .enterVerificationCode) + #expect(Step(type: .sms, status: .verificationPending) == .enterVerificationCode) + } + + @Test("TOTP sessions map to the authenticator step") + func totpSessionSteps() { + #expect(Step(type: .totp, status: .initiated) == .setUpTOTP) + #expect(Step(type: .totp, status: .verificationPending) == .setUpTOTP) + } + + @Test("Finished sessions go back to factor selection") + func finishedSessionsSelectFactor() { + let statuses: [EnrollmentSession.EnrollmentStatus] = [.completed, .failed, .expired] + for status in statuses { + #expect(Step(type: .sms, status: status) == .selectFactor) + #expect(Step(type: .totp, status: status) == .selectFactor) + } + } + + @Test("Factor selection needs MFA enabled and an allowed factor") + func selectFactorValidity() { + #expect(isValid(.selectFactor)) + #expect(!isValid(.selectFactor, isMFAEnabled: false)) + #expect(!isValid(.selectFactor, allowedFactors: [])) + #expect(!isValid(.selectFactor, allowedFactors: [.sms], selectedFactorType: .totp)) + } + + @Test("Phone number step needs a number and a display name") + func enterPhoneNumberValidity() { + #expect(isValid(.enterPhoneNumber, phoneNumber: "5551234567", displayName: "Phone")) + #expect(!isValid(.enterPhoneNumber, phoneNumber: "", displayName: "Phone")) + #expect(!isValid(.enterPhoneNumber, phoneNumber: "5551234567", displayName: "")) + } + + @Test("Code steps need their own code and a display name") + func codeStepValidity() { + #expect(isValid(.enterVerificationCode, verificationCode: "123456", displayName: "Phone")) + #expect(!isValid(.enterVerificationCode, totpCode: "123456", displayName: "Phone")) + #expect(!isValid(.enterVerificationCode, verificationCode: "123456", displayName: "")) + #expect(isValid(.setUpTOTP, totpCode: "123456", displayName: "App")) + #expect(!isValid(.setUpTOTP, verificationCode: "123456", displayName: "App")) + #expect(!isValid(.setUpTOTP, totpCode: "123456", displayName: "")) + } + + private func isValid(_ step: Step, + isMFAEnabled: Bool = true, + allowedFactors: [SecondFactorType] = [.sms, .totp], + selectedFactorType: SecondFactorType = .sms, + phoneNumber: String = "", + verificationCode: String = "", + totpCode: String = "", + displayName: String = "") -> Bool { + step.isValid( + isMFAEnabled: isMFAEnabled, + allowedFactors: allowedFactors, + selectedFactorType: selectedFactorType, + phoneNumber: phoneNumber, + verificationCode: verificationCode, + totpCode: totpCode, + displayName: displayName + ) + } +} From fe906592b9c1cc51e0e33b7ea472c79e6d12f779 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:35:25 +0100 Subject: [PATCH 6/7] fix(auth): return expired MFA enrolment sessions to factor selection Map sessions past expiresAt back to factor selection, only send codes from the SMS steps, disable Resend Code while loading, and note that custom content sets its own navigation title. --- .../Views/DefaultMFAEnrolmentContent.swift | 1 + .../Sources/Views/MFAEnrolmentScreen.swift | 29 ++++++++++++------- .../MFAEnrolmentScreenTests.swift | 23 ++++++++++----- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAEnrolmentContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAEnrolmentContent.swift index 7bea3ef340..c29fdd13d8 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAEnrolmentContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAEnrolmentContent.swift @@ -307,6 +307,7 @@ public struct DefaultMFAEnrolmentContent: View { .frame(maxWidth: .infinity) } .buttonStyle(.bordered) + .disabled(state.isLoading) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .accessibilityIdentifier("resend-code-button") diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentScreen.swift index 0c8ef10efd..4806bbad70 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentScreen.swift @@ -22,8 +22,8 @@ public struct MFAEnrollmentContentState { /// /// Later releases may add steps, so give a `switch` over it a `default` case. public enum Step { - /// Choosing a factor, before ``MFAEnrollmentContentState/startEnrollment`` succeeds or after - /// an enrolment session completed, failed or expired. + /// Choosing a factor, before ``MFAEnrollmentContentState/startEnrollment`` succeeds or once + /// the enrolment session has expired or finished. case selectFactor /// Entering the phone number and display name for an SMS factor. case enterPhoneNumber @@ -62,8 +62,9 @@ public struct MFAEnrollmentContentState { public let startEnrollment: @MainActor () -> Void /// Sends a code to the full phone number, moving to ``Step/enterVerificationCode``, or sends a - /// new one from that step. Does nothing unless an SMS enrolment has started, ``phoneNumber`` - /// and ``displayName`` are non-empty, and ``isLoading`` is `false`. + /// new one from that step. Does nothing unless ``step`` is ``Step/enterPhoneNumber`` or + /// ``Step/enterVerificationCode``, ``phoneNumber`` and ``displayName`` are non-empty, and + /// ``isLoading`` is `false`. public let sendCode: @MainActor () -> Void /// Enrols the factor with the SMS or authenticator code, reauthenticating first when Firebase @@ -157,7 +158,8 @@ public struct MFAEnrollmentContentState { /// The screen starts the enrolment, sends SMS codes, enrols the factor, presents /// reauthentication when Firebase requires it, and reports errors. Your content only draws the /// UI for the ``MFAEnrollmentContentState/step`` it receives. Wrap ``DefaultMFAEnrolmentContent`` -/// to keep the stock markup. +/// to keep the stock markup. The navigation title is part of that markup, so set your own on +/// custom content. /// /// Example: /// ```swift @@ -248,7 +250,11 @@ public struct MFAEnrolmentScreen: View { private var step: MFAEnrollmentContentState.Step { guard let session = currentSession else { return .selectFactor } - return MFAEnrollmentContentState.Step(type: session.type, status: session.status) + return MFAEnrollmentContentState.Step( + type: session.type, + status: session.status, + isExpired: session.isExpired + ) } private var isValid: Bool { @@ -286,7 +292,8 @@ public struct MFAEnrolmentScreen: View { } private func sendCode() { - guard let session = currentSession, session.type == .sms, + guard let session = currentSession, + step == .enterPhoneNumber || step == .enterVerificationCode, !phoneNumber.isEmpty, !displayName.isEmpty, !isLoading else { return } isWorking = true errorMessage = nil @@ -379,11 +386,11 @@ extension MFAEnrollmentContentState { } extension MFAEnrollmentContentState.Step { - /// The step for a started enrolment session. Sessions that completed, failed or expired go back - /// to ``selectFactor`` so the user can start again. - init(type: SecondFactorType, status: EnrollmentSession.EnrollmentStatus) { + /// The step for a started enrolment session. Sessions that have expired or finished go back to + /// ``selectFactor`` so the user can start again. + init(type: SecondFactorType, status: EnrollmentSession.EnrollmentStatus, isExpired: Bool) { switch (type, status) { - case (_, .completed), (_, .failed), (_, .expired): + case _ where isExpired, (_, .completed), (_, .failed), (_, .expired): self = .selectFactor case (.sms, .initiated): self = .enterPhoneNumber diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentScreenTests.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentScreenTests.swift index a84d1390a5..ec743003c0 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentScreenTests.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentScreenTests.swift @@ -28,26 +28,35 @@ struct MFAEnrolmentScreenTests { @Test("SMS sessions map to the phone number and code steps") func smsSessionSteps() { - #expect(Step(type: .sms, status: .initiated) == .enterPhoneNumber) - #expect(Step(type: .sms, status: .verificationSent) == .enterVerificationCode) - #expect(Step(type: .sms, status: .verificationPending) == .enterVerificationCode) + #expect(Step(type: .sms, status: .initiated, isExpired: false) == .enterPhoneNumber) + #expect(Step(type: .sms, status: .verificationSent, isExpired: false) + == .enterVerificationCode) + #expect(Step(type: .sms, status: .verificationPending, isExpired: false) + == .enterVerificationCode) } @Test("TOTP sessions map to the authenticator step") func totpSessionSteps() { - #expect(Step(type: .totp, status: .initiated) == .setUpTOTP) - #expect(Step(type: .totp, status: .verificationPending) == .setUpTOTP) + #expect(Step(type: .totp, status: .initiated, isExpired: false) == .setUpTOTP) + #expect(Step(type: .totp, status: .verificationPending, isExpired: false) == .setUpTOTP) } @Test("Finished sessions go back to factor selection") func finishedSessionsSelectFactor() { let statuses: [EnrollmentSession.EnrollmentStatus] = [.completed, .failed, .expired] for status in statuses { - #expect(Step(type: .sms, status: status) == .selectFactor) - #expect(Step(type: .totp, status: status) == .selectFactor) + #expect(Step(type: .sms, status: status, isExpired: false) == .selectFactor) + #expect(Step(type: .totp, status: status, isExpired: false) == .selectFactor) } } + @Test("Expired sessions go back to factor selection") + func expiredSessionsSelectFactor() { + #expect(Step(type: .sms, status: .initiated, isExpired: true) == .selectFactor) + #expect(Step(type: .sms, status: .verificationSent, isExpired: true) == .selectFactor) + #expect(Step(type: .totp, status: .initiated, isExpired: true) == .selectFactor) + } + @Test("Factor selection needs MFA enabled and an allowed factor") func selectFactorValidity() { #expect(isValid(.selectFactor)) From 055090febf933fed14edbede5e4163c34487fd07 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:49:37 +0100 Subject: [PATCH 7/7] test(auth): cover MFA enrolment action guards Move the startEnrollment, sendCode and enroll guards into Step helpers with unit tests, and replace the unused focus enum with a single phone number focus flag. --- .../Views/DefaultMFAEnrolmentContent.swift | 12 ++----- .../Sources/Views/MFAEnrolmentScreen.swift | 28 ++++++++++++--- .../MFAEnrolmentScreenTests.swift | 35 +++++++++++++++++++ 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAEnrolmentContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAEnrolmentContent.swift index c29fdd13d8..dd49fbc403 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAEnrolmentContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAEnrolmentContent.swift @@ -15,12 +15,6 @@ import FirebaseAuthUIComponents import SwiftUI -private enum FocusableField: Hashable { - case phoneNumber - case verificationCode - case totpCode -} - /// The stock content of ``MFAEnrolmentScreen``. @MainActor public struct DefaultMFAEnrolmentContent: View { @@ -29,7 +23,7 @@ public struct DefaultMFAEnrolmentContent: View { } @Environment(AuthService.self) private var authService - @FocusState private var focus: FocusableField? + @FocusState private var isPhoneNumberFocused: Bool private let state: MFAEnrollmentContentState public var body: some View { @@ -119,7 +113,7 @@ public struct DefaultMFAEnrolmentContent: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .safeAreaPadding() .navigationTitle("Two-Factor Authentication") - .onChange(of: state.step) { focus = nil } + .onChange(of: state.step) { isPhoneNumberFocused = false } } private var initialContent: some View { @@ -217,7 +211,7 @@ public struct DefaultMFAEnrolmentContent: View { enabled: !state.isLoading ) } - .focused($focus, equals: .phoneNumber) + .focused($isPhoneNumberFocused) .accessibilityIdentifier("phone-number-field") AuthTextField( diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentScreen.swift index 4806bbad70..1b0f364a59 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentScreen.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentScreen.swift @@ -274,7 +274,7 @@ public struct MFAEnrolmentScreen: View { } private func startEnrollment() { - guard step == .selectFactor, isValid, !isLoading else { return } + guard step.canStartEnrollment(isValid: isValid, isLoading: isLoading) else { return } isWorking = true errorMessage = nil Task { @@ -293,8 +293,11 @@ public struct MFAEnrolmentScreen: View { private func sendCode() { guard let session = currentSession, - step == .enterPhoneNumber || step == .enterVerificationCode, - !phoneNumber.isEmpty, !displayName.isEmpty, !isLoading else { return } + step.canSendCode( + phoneNumber: phoneNumber, + displayName: displayName, + isLoading: isLoading + ) else { return } isWorking = true errorMessage = nil Task { @@ -324,8 +327,7 @@ public struct MFAEnrolmentScreen: View { private func enroll() { guard let session = currentSession, - step == .enterVerificationCode || step == .setUpTOTP, - isValid, !isLoading else { return } + step.canEnroll(isValid: isValid, isLoading: isLoading) else { return } isWorking = true errorMessage = nil Task { @@ -420,4 +422,20 @@ extension MFAEnrollmentContentState.Step { !totpCode.isEmpty && !displayName.isEmpty } } + + /// Whether ``MFAEnrollmentContentState/startEnrollment`` runs from this step. + func canStartEnrollment(isValid: Bool, isLoading: Bool) -> Bool { + self == .selectFactor && isValid && !isLoading + } + + /// Whether ``MFAEnrollmentContentState/sendCode`` runs from this step. + func canSendCode(phoneNumber: String, displayName: String, isLoading: Bool) -> Bool { + (self == .enterPhoneNumber || self == .enterVerificationCode) + && !phoneNumber.isEmpty && !displayName.isEmpty && !isLoading + } + + /// Whether ``MFAEnrollmentContentState/enroll`` runs from this step. + func canEnroll(isValid: Bool, isLoading: Bool) -> Bool { + (self == .enterVerificationCode || self == .setUpTOTP) && isValid && !isLoading + } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentScreenTests.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentScreenTests.swift index ec743003c0..89c667e6b1 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentScreenTests.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentScreenTests.swift @@ -82,6 +82,41 @@ struct MFAEnrolmentScreenTests { #expect(!isValid(.setUpTOTP, totpCode: "123456", displayName: "")) } + @Test("Get Started runs only from factor selection") + func startEnrollmentGuard() { + #expect(Step.selectFactor.canStartEnrollment(isValid: true, isLoading: false)) + #expect(!Step.selectFactor.canStartEnrollment(isValid: false, isLoading: false)) + #expect(!Step.selectFactor.canStartEnrollment(isValid: true, isLoading: true)) + for step in [Step.enterPhoneNumber, .enterVerificationCode, .setUpTOTP] { + #expect(!step.canStartEnrollment(isValid: true, isLoading: false)) + } + } + + @Test("Codes are sent only from the SMS steps") + func sendCodeGuard() { + for step in [Step.enterPhoneNumber, .enterVerificationCode] { + #expect(step.canSendCode(phoneNumber: "5551234567", displayName: "Phone", isLoading: false)) + #expect(!step.canSendCode(phoneNumber: "", displayName: "Phone", isLoading: false)) + #expect(!step.canSendCode(phoneNumber: "5551234567", displayName: "", isLoading: false)) + #expect(!step.canSendCode(phoneNumber: "5551234567", displayName: "Phone", isLoading: true)) + } + for step in [Step.selectFactor, .setUpTOTP] { + #expect(!step.canSendCode(phoneNumber: "5551234567", displayName: "Phone", isLoading: false)) + } + } + + @Test("Enrolment runs only from the code steps") + func enrollGuard() { + for step in [Step.enterVerificationCode, .setUpTOTP] { + #expect(step.canEnroll(isValid: true, isLoading: false)) + #expect(!step.canEnroll(isValid: false, isLoading: false)) + #expect(!step.canEnroll(isValid: true, isLoading: true)) + } + for step in [Step.selectFactor, .enterPhoneNumber] { + #expect(!step.canEnroll(isValid: true, isLoading: false)) + } + } + private func isValid(_ step: Step, isMFAEnabled: Bool = true, allowedFactors: [SecondFactorType] = [.sms, .totp],