From fb0634d46b03984b9a259253823ea3532adbcb10 Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 6 Jul 2026 20:03:20 +0100 Subject: [PATCH 01/25] Adjust EmailAuthView label font weights and drop hardcoded blue tint on the auth-flow switch label --- .../FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index fc6616a8f8..2fa44811d3 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -150,6 +150,7 @@ extension EmailAuthView: View { authService.navigator.push(.passwordRecovery) } label: { Text(authService.string.passwordButtonLabel) + .fontWeight(.medium) .frame(maxWidth: .infinity, alignment: .trailing) } .accessibilityIdentifier("password-recovery-button") @@ -228,8 +229,7 @@ extension EmailAuthView: View { ? authService.string.emailLoginFlowLabel : authService.string.emailSignUpFlowLabel ) - .fontWeight(.semibold) - .foregroundColor(.blue) + .fontWeight(.medium) } } .accessibilityIdentifier("switch-auth-flow") From dba5839e570ad2c1e67c48f7742bc19af4ece76d Mon Sep 17 00:00:00 2001 From: demolaf Date: Mon, 6 Jul 2026 23:12:01 +0100 Subject: [PATCH 02/25] Add AuthTextFieldStyle and AuthCTAButtonStyle for environment-driven theming of text fields and CTA buttons --- .../Sources/Views/AuthCTAButtonModifier.swift | 79 +++++++++++++++++++ .../Sources/Views/EmailAuthView.swift | 2 +- .../Sources/Views/EmailLinkView.swift | 2 +- .../Sources/Views/EmailReauthView.swift | 2 +- .../Sources/Views/EnterPhoneNumberView.swift | 2 +- .../Views/EnterVerificationCodeView.swift | 2 +- .../Sources/Views/MFAEnrolmentView.swift | 8 +- .../Sources/Views/MFAManagementView.swift | 4 +- .../Sources/Views/PasswordRecoveryView.swift | 2 +- .../Sources/Views/PhoneReauthView.swift | 4 +- .../Sources/Views/SignedInView.swift | 12 +-- .../Sources/Views/UpdatePasswordView.swift | 2 +- .../Sources/Components/AuthTextField.swift | 15 ++-- .../Sources/Theme/AuthTextFieldStyle.swift | 69 ++++++++++++++++ 14 files changed, 179 insertions(+), 26 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTextFieldStyle.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift new file mode 100644 index 0000000000..a83e96c379 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift @@ -0,0 +1,79 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SwiftUI + +/// Styling configuration for the primary call-to-action buttons used throughout the auth flow +/// (sign in, send code, update password, etc). Color already follows the environment's `.tint()` +/// via `.buttonStyle(.borderedProminent)`; this covers what `.tint()` doesn't — shape and font. +/// Both fields default to `nil`, in which case today's exact appearance is unchanged. +public struct AuthCTAButtonStyle: Sendable { + public var shape: ButtonBorderShape? + public var font: Font? + + public init(shape: ButtonBorderShape? = nil, font: Font? = nil) { + self.shape = shape + self.font = font + } + + public static let `default` = AuthCTAButtonStyle() +} + +private struct AuthCTAButtonStyleKey: EnvironmentKey { + static let defaultValue: AuthCTAButtonStyle = .default +} + +public extension EnvironmentValues { + var authCTAButtonStyle: AuthCTAButtonStyle { + get { self[AuthCTAButtonStyleKey.self] } + set { self[AuthCTAButtonStyleKey.self] = newValue } + } +} + +/// Applies `.buttonStyle(.borderedProminent)` plus the shape/font from the environment's +/// ``AuthCTAButtonStyle``, in place of a bare `.buttonStyle(.borderedProminent)` call. +struct AuthCTAButtonModifier: ViewModifier { + @Environment(\.authCTAButtonStyle) private var style + + func body(content: Content) -> some View { + if let font = style.font { + content + .buttonStyle(.borderedProminent) + .buttonBorderShape(style.shape ?? .automatic) + .font(font) + } else { + content + .buttonStyle(.borderedProminent) + .buttonBorderShape(style.shape ?? .automatic) + } + } +} + +public extension View { + /// Applies the auth flow's primary call-to-action button styling, reading shape/font from + /// the environment's ``AuthCTAButtonStyle`` (set via `.authCTAButtonStyle(_:)`). + func authCTAButtonStyle() -> some View { + modifier(AuthCTAButtonModifier()) + } + + /// Sets the ``AuthCTAButtonStyle`` used by every CTA button in this view's subtree. + /// + /// ```swift + /// AuthPickerView { ... } + /// .authCTAButtonStyle(AuthCTAButtonStyle(shape: .capsule)) + /// ``` + func authCTAButtonStyle(_ style: AuthCTAButtonStyle) -> some View { + environment(\.authCTAButtonStyle, style) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index 2fa44811d3..a7020f8b7f 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -206,7 +206,7 @@ extension EmailAuthView: View { .disabled(!isValid) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .accessibilityIdentifier("sign-in-button") } Button(action: { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift index 6bdfe6812a..c64c86e570 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift @@ -68,7 +68,7 @@ extension EmailLinkView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(!CommonUtils.isValidEmail(email)) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift index 8d61630ce4..b1d8077d72 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift @@ -109,7 +109,7 @@ extension EmailReauthView: View { .frame(maxWidth: .infinity) } } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(password.isEmpty || isLoading) .accessibilityIdentifier("confirm-password-button") diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift index 95152335ca..62ef77969f 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift @@ -77,7 +77,7 @@ struct EnterPhoneNumberView: View { .frame(maxWidth: .infinity) } } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(authService.authenticationState == .authenticating || phoneNumber.isEmpty) .padding(.top, 8) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift index 2a1da9d406..700b63806e 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift @@ -87,7 +87,7 @@ struct EnterVerificationCodeView: View { .frame(maxWidth: .infinity) } } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(authService.authenticationState == .authenticating || verificationCode.count != 6) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift index 75b13b4765..e3274f3d95 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift @@ -349,7 +349,7 @@ extension MFAEnrolmentView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(!canStartEnrollment) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) @@ -435,7 +435,7 @@ extension MFAEnrolmentView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(!canSendSMSVerification) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) @@ -480,7 +480,7 @@ extension MFAEnrolmentView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(!canCompleteEnrollment) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) @@ -635,7 +635,7 @@ extension MFAEnrolmentView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(!canCompleteEnrollment) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift index eeeddb45b0..592cfee19c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift @@ -104,7 +104,7 @@ extension MFAManagementView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .accessibilityIdentifier("setup-mfa-button") @@ -128,7 +128,7 @@ extension MFAManagementView: View { } .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .accessibilityIdentifier("add-mfa-method-button") } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift index af14a1c167..80285bba2e 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift @@ -63,7 +63,7 @@ extension PasswordRecoveryView: View { } .disabled(!CommonUtils.isValidEmail(email)) .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .navigationTitle(authService.string.passwordRecoveryTitle) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift index 10821e9b7c..df2ca7f894 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift @@ -127,7 +127,7 @@ extension PhoneReauthView: View { .frame(maxWidth: .infinity) } } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(isLoading) .accessibilityIdentifier("send-verification-code-button") Button(authService.string.cancelButtonLabel) { @@ -170,7 +170,7 @@ extension PhoneReauthView: View { .frame(maxWidth: .infinity) } } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .disabled(verificationCode.count != 6 || isLoading) .accessibilityIdentifier("verify-button") diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift index e996ccc5b3..759481a261 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift @@ -58,7 +58,7 @@ extension SignedInView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .accessibilityIdentifier("verify-email-button") @@ -70,7 +70,7 @@ extension SignedInView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .accessibilityIdentifier("update-password-button") @@ -83,7 +83,7 @@ extension SignedInView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .accessibilityIdentifier("mfa-management-button") @@ -96,7 +96,7 @@ extension SignedInView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .accessibilityIdentifier("delete-account-button") @@ -118,7 +118,7 @@ extension SignedInView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .accessibilityIdentifier("sign-out-button") @@ -198,7 +198,7 @@ private struct DeleteAccountConfirmationSheet: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() .tint(.red) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift index 8480beb818..9e6e3912c1 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift @@ -104,7 +104,7 @@ extension UpdatePasswordView: View { .disabled(!isValid) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) + .authCTAButtonStyle() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .safeAreaPadding() diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift index 8e96711399..636ad44dd8 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift @@ -15,6 +15,7 @@ import SwiftUI public struct AuthTextField: View { + @Environment(\.authTextFieldStyle) private var style @FocusState private var isFocused: Bool @State var obscured: Bool = true @State var hasInteracted: Bool = false @@ -131,10 +132,14 @@ public struct AuthTextField: View { .padding(.vertical, 12) .padding(.horizontal, 12) .background { - RoundedRectangle(cornerRadius: 8) - .fill(Color.accentColor.opacity(0.05)) + RoundedRectangle(cornerRadius: style.cornerRadius ?? 8) + .fill((style.tint ?? Color.accentColor).opacity(0.05)) .strokeBorder(lineWidth: isFocused ? 3 : 1) - .foregroundStyle(isFocused ? Color.accentColor : Color(.systemFill)) + .foregroundStyle( + isFocused + ? (style.tint ?? Color.accentColor) + : (style.containerColor ?? Color(.systemFill)) + ) } .contentShape(Rectangle()) .onTapGesture { @@ -149,8 +154,8 @@ public struct AuthTextField: View { let isValid = validator.isValid(input: text) Text(validator.message) .font(.caption) - .strikethrough(isValid, color: .gray) - .foregroundStyle(isValid ? .gray : .red) + .strikethrough(isValid, color: style.secondaryColor ?? .gray) + .foregroundStyle(isValid ? (style.secondaryColor ?? .gray) : (style.errorColor ?? .red)) .fixedSize(horizontal: false, vertical: true) } } diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTextFieldStyle.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTextFieldStyle.swift new file mode 100644 index 0000000000..6fc0dc405c --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTextFieldStyle.swift @@ -0,0 +1,69 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SwiftUI + +/// Styling configuration for ``AuthTextField``. All fields default to `nil`, in which case +/// `AuthTextField` falls back to its existing hardcoded appearance. +public struct AuthTextFieldStyle: Sendable { + public var tint: Color? + public var containerColor: Color? + public var secondaryColor: Color? + public var errorColor: Color? + public var cornerRadius: CGFloat? + + public init(tint: Color? = nil, + containerColor: Color? = nil, + secondaryColor: Color? = nil, + errorColor: Color? = nil, + cornerRadius: CGFloat? = nil) { + self.tint = tint + self.containerColor = containerColor + self.secondaryColor = secondaryColor + self.errorColor = errorColor + self.cornerRadius = cornerRadius + } + + public static let `default` = AuthTextFieldStyle() +} + +private struct AuthTextFieldStyleKey: EnvironmentKey { + static let defaultValue: AuthTextFieldStyle = .default +} + +public extension EnvironmentValues { + var authTextFieldStyle: AuthTextFieldStyle { + get { self[AuthTextFieldStyleKey.self] } + set { self[AuthTextFieldStyleKey.self] = newValue } + } +} + +public extension View { + /// Applies a custom appearance to every ``AuthTextField`` in this view's subtree. + /// + /// ```swift + /// AuthPickerView { ... } + /// .authTextFieldStyle( + /// AuthTextFieldStyle( + /// tint: theme.colors.tint, + /// containerColor: theme.colors.container, + /// secondaryColor: theme.colors.secondary, + /// errorColor: theme.colors.error + /// ) + /// ) + /// ``` + func authTextFieldStyle(_ style: AuthTextFieldStyle) -> some View { + environment(\.authTextFieldStyle, style) + } +} From 452606064eba02409daf2fd7768d0faed0614dd4 Mon Sep 17 00:00:00 2001 From: demolaf Date: Tue, 7 Jul 2026 01:44:30 +0100 Subject: [PATCH 03/25] Add AuthTypography for environment-driven custom font family, and close remaining unstyled-text gaps across the auth flow --- .../Sources/Views/AuthCTAButtonModifier.swift | 61 ++++++++--- .../Sources/Views/EmailAuthView.swift | 5 +- .../Sources/Views/EmailLinkReauthView.swift | 11 +- .../Sources/Views/EmailReauthView.swift | 6 +- .../Sources/Views/EnterPhoneNumberView.swift | 2 +- .../Views/EnterVerificationCodeView.swift | 4 +- .../Views/LegacySignInRecoveryView.swift | 6 +- .../Sources/Views/MFAEnrolmentView.swift | 47 ++++---- .../Sources/Views/MFAManagementView.swift | 20 ++-- .../Sources/Views/MFAResolutionView.swift | 30 +++--- .../Sources/Views/PasswordRecoveryView.swift | 4 +- .../Sources/Views/PhoneReauthView.swift | 12 +-- .../Sources/Views/PrivacyTOCsView.swift | 2 + .../Sources/Views/SignedInView.swift | 8 +- .../Components/AuthProviderButton.swift | 1 + .../Sources/Components/AuthTextField.swift | 10 +- .../Sources/Components/CountrySelector.swift | 7 +- .../VerificationCodeInputField.swift | 16 +-- .../Sources/Theme/AuthTypography.swift | 101 ++++++++++++++++++ 19 files changed, 251 insertions(+), 102 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift index a83e96c379..89f2a4ec5a 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift @@ -12,17 +12,26 @@ // See the License for the specific language governing permissions and // limitations under the License. +import FirebaseAuthUIComponents import SwiftUI /// Styling configuration for the primary call-to-action buttons used throughout the auth flow -/// (sign in, send code, update password, etc). Color already follows the environment's `.tint()` -/// via `.buttonStyle(.borderedProminent)`; this covers what `.tint()` doesn't — shape and font. -/// Both fields default to `nil`, in which case today's exact appearance is unchanged. +/// (sign in, send code, update password, etc). All fields default to `nil`, in which case +/// today's exact appearance is unchanged — including `backgroundColor`, which otherwise already +/// follows the environment's `.tint()` via `.buttonStyle(.borderedProminent)`; setting it here +/// overrides that for CTA buttons specifically, without affecting `.tint()` elsewhere. public struct AuthCTAButtonStyle: Sendable { + public var backgroundColor: Color? + public var contentColor: Color? public var shape: ButtonBorderShape? public var font: Font? - public init(shape: ButtonBorderShape? = nil, font: Font? = nil) { + public init(backgroundColor: Color? = nil, + contentColor: Color? = nil, + shape: ButtonBorderShape? = nil, + font: Font? = nil) { + self.backgroundColor = backgroundColor + self.contentColor = contentColor self.shape = shape self.font = font } @@ -41,28 +50,44 @@ public extension EnvironmentValues { } } -/// Applies `.buttonStyle(.borderedProminent)` plus the shape/font from the environment's -/// ``AuthCTAButtonStyle``, in place of a bare `.buttonStyle(.borderedProminent)` call. +/// Applies `.buttonStyle(.borderedProminent)` plus the colors/shape/font from the environment's +/// ``AuthCTAButtonStyle``, in place of a bare `.buttonStyle(.borderedProminent)` call. When +/// `style.font` is left unset, the button falls back to ``AuthTypography`` (via `.authFont(_:)`) +/// so it stays consistent with the rest of the auth flow's typography by default — an explicit +/// `style.font` still overrides that, for buttons that should intentionally look different. struct AuthCTAButtonModifier: ViewModifier { @Environment(\.authCTAButtonStyle) private var style func body(content: Content) -> some View { - if let font = style.font { - content - .buttonStyle(.borderedProminent) - .buttonBorderShape(style.shape ?? .automatic) - .font(font) + Group { + if let contentColor = style.contentColor { + content.foregroundStyle(contentColor) + } else { + content + } + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(style.shape ?? .automatic) + .tint(style.backgroundColor) + .modifier(CTAFontModifier(explicitFont: style.font)) + } +} + +private struct CTAFontModifier: ViewModifier { + let explicitFont: Font? + + func body(content: Content) -> some View { + if let explicitFont { + content.font(explicitFont) } else { - content - .buttonStyle(.borderedProminent) - .buttonBorderShape(style.shape ?? .automatic) + content.authFont(.headline) } } } public extension View { - /// Applies the auth flow's primary call-to-action button styling, reading shape/font from - /// the environment's ``AuthCTAButtonStyle`` (set via `.authCTAButtonStyle(_:)`). + /// Applies the auth flow's primary call-to-action button styling, reading colors/shape/font + /// from the environment's ``AuthCTAButtonStyle`` (set via `.authCTAButtonStyle(_:)`). func authCTAButtonStyle() -> some View { modifier(AuthCTAButtonModifier()) } @@ -71,7 +96,9 @@ public extension View { /// /// ```swift /// AuthPickerView { ... } - /// .authCTAButtonStyle(AuthCTAButtonStyle(shape: .capsule)) + /// .authCTAButtonStyle( + /// AuthCTAButtonStyle(backgroundColor: theme.colors.tint, contentColor: .white, shape: .capsule) + /// ) /// ``` func authCTAButtonStyle(_ style: AuthCTAButtonStyle) -> some View { environment(\.authCTAButtonStyle, style) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index a7020f8b7f..5f59199573 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -150,7 +150,7 @@ extension EmailAuthView: View { authService.navigator.push(.passwordRecovery) } label: { Text(authService.string.passwordButtonLabel) - .fontWeight(.medium) + .authFont(.body, weight: .medium) .frame(maxWidth: .infinity, alignment: .trailing) } .accessibilityIdentifier("password-recovery-button") @@ -223,13 +223,14 @@ extension EmailAuthView: View { ? authService.string.dontHaveAnAccountYetLabel : authService.string.alreadyHaveAnAccountLabel ) + .authFont(.body) .foregroundStyle(Color(.label)) Text( authService.authenticationFlow == .signUp ? authService.string.emailLoginFlowLabel : authService.string.emailSignUpFlowLabel ) - .fontWeight(.medium) + .authFont(.body, weight: .medium) } } .accessibilityIdentifier("switch-auth-flow") diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift index f151cda431..04f5ab8c54 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift @@ -13,6 +13,7 @@ // limitations under the License. import FirebaseAuth +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -81,20 +82,20 @@ extension EmailLinkReauthView: View { .padding(.top, 32) Text("Check Your Email") - .font(.title) + .authFont(.title) .fontWeight(.bold) Text("We've sent a verification link to:") - .font(.body) + .authFont(.body) .foregroundStyle(.secondary) Text(email) - .font(.body) + .authFont(.body) .fontWeight(.medium) .padding(.horizontal) Text("Tap the link in the email to complete reauthentication.") - .font(.body) + .authFont(.body) .multilineTextAlignment(.center) .foregroundStyle(.secondary) .padding(.horizontal, 32) @@ -110,6 +111,7 @@ extension EmailLinkReauthView: View { .frame(height: 32) } else { Text("Resend Email") + .authFont(.body) .frame(height: 32) } } @@ -123,6 +125,7 @@ extension EmailLinkReauthView: View { ProgressView() .padding(.top, 32) Text("Sending verification email...") + .authFont(.body) .foregroundStyle(.secondary) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift index b1d8077d72..34a3fb497b 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift @@ -66,11 +66,11 @@ extension EmailReauthView: View { .foregroundColor(.blue) Text(authService.string.confirmPasswordTitle) - .font(.title) + .authFont(.title) .fontWeight(.bold) Text(authService.string.forSecurityEnterPasswordMessage) - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -78,7 +78,7 @@ extension EmailReauthView: View { VStack(spacing: 20) { Text(authService.string.emailPrefix(email: email)) - .font(.caption) + .authFont(.caption) .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 8) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift index 62ef77969f..54e41c1fa8 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift @@ -26,7 +26,7 @@ struct EnterPhoneNumberView: View { var body: some View { VStack(spacing: 16) { Text(authService.string.enterPhoneNumberPlaceholder) - .font(.subheadline) + .authFont(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift index 700b63806e..3a644e713c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift @@ -33,7 +33,7 @@ struct EnterVerificationCodeView: View { VStack(spacing: 16) { VStack(spacing: 8) { Text(authService.string.sentCodeMessage(phoneNumber: fullPhoneNumber)) - .font(.subheadline) + .authFont(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: .infinity, alignment: .leading) @@ -42,7 +42,7 @@ struct EnterVerificationCodeView: View { authService.navigator.pop() } label: { Text(authService.string.changeNumberButtonLabel) - .font(.caption) + .authFont(.caption) .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/LegacySignInRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/LegacySignInRecoveryView.swift index 11c1d35367..d49a600037 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/LegacySignInRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/LegacySignInRecoveryView.swift @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -26,8 +27,9 @@ struct LegacySignInRecoveryView: View { VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 12) { Text(authService.string.legacySignInRecoveryTitle) - .font(.title2.weight(.semibold)) + .authFont(.title2, weight: .semibold) Text(authService.string.legacySignInRecoveryMessage(email: recovery.email)) + .authFont(.body) .foregroundStyle(.secondary) } @@ -35,7 +37,7 @@ struct LegacySignInRecoveryView: View { if !recovery.unavailableProviders.isEmpty { Text(authService.string.legacySignInRecoveryUnavailableMessage) - .font(.footnote) + .authFont(.footnote) .foregroundStyle(.secondary) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift index e3274f3d95..c5928223f8 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift @@ -210,12 +210,12 @@ extension MFAEnrolmentView: View { if currentSession == nil { VStack(spacing: 8) { Text("Set Up Two-Factor Authentication") - .font(.largeTitle) + .authFont(.largeTitle) .fontWeight(.bold) .multilineTextAlignment(.center) Text("Add an extra layer of security to your account") - .font(.subheadline) + .authFont(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -230,13 +230,13 @@ extension MFAEnrolmentView: View { .foregroundColor(.orange) Text("Multi-Factor Authentication Disabled") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text( "MFA is not enabled in the current configuration. Please contact your administrator." ) - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -248,11 +248,11 @@ extension MFAEnrolmentView: View { .foregroundColor(.orange) Text("No Authentication Methods Available") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text("No MFA methods are configured as allowed. Please contact your administrator.") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -260,7 +260,7 @@ extension MFAEnrolmentView: View { } else { VStack(alignment: .leading, spacing: 12) { Text("Choose Authentication Method") - .font(.headline) + .authFont(.headline) Picker("Authentication Method", selection: $selectedFactorType) { ForEach(allowedFactorTypes, id: \.self) { factorType in @@ -309,11 +309,11 @@ extension MFAEnrolmentView: View { .foregroundColor(.blue) Text("SMS Authentication") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text("We'll send a verification code to your phone number each time you sign in.") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -324,13 +324,13 @@ extension MFAEnrolmentView: View { .foregroundColor(.green) Text("Authenticator App") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text( "Use an authenticator app like Google Authenticator or Authy to generate verification codes." ) - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -379,11 +379,11 @@ extension MFAEnrolmentView: View { .foregroundColor(.blue) Text("Enter Your Phone Number") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text("We'll send a verification code to this number") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -449,11 +449,11 @@ extension MFAEnrolmentView: View { .foregroundColor(.green) Text("Enter Verification Code") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text("We sent a code to \(session.phoneNumber ?? "your phone")") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -490,6 +490,7 @@ extension MFAEnrolmentView: View { sendSMSVerification() } label: { Text("Resend Code") + .authFont(.body) .padding(.vertical, 8) .frame(maxWidth: .infinity) } @@ -513,11 +514,11 @@ extension MFAEnrolmentView: View { .foregroundColor(.green) Text("Scan QR Code") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text("Scan with your authenticator app or tap to open directly") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) .lineLimit(nil) @@ -540,9 +541,9 @@ extension MFAEnrolmentView: View { HStack(spacing: 6) { Image(systemName: "arrow.up.forward.app.fill") - .font(.caption) + .authFont(.caption) Text("Tap to open in authenticator app") - .font(.caption) + .authFont(.caption) .fontWeight(.medium) } .foregroundColor(.blue) @@ -557,17 +558,17 @@ extension MFAEnrolmentView: View { .overlay( VStack { Image(systemName: "exclamationmark.triangle") - .font(.title) + .authFont(.title) .foregroundColor(.orange) Text("Unable to generate QR Code") - .font(.caption) + .authFont(.caption) } ) } VStack(spacing: 6) { Text("Manual Entry Key:") - .font(.headline) + .authFont(.headline) Button(action: { copyToClipboard(totpInfo.sharedSecretKey) @@ -592,7 +593,7 @@ extension MFAEnrolmentView: View { if showCopiedFeedback { Text("Copied to clipboard!") - .font(.caption) + .authFont(.caption) .foregroundColor(.green) .transition(.opacity) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift index 592cfee19c..2d337dd2c8 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift @@ -67,12 +67,12 @@ extension MFAManagementView: View { // Title section VStack { Text("Two-Factor Authentication") - .font(.largeTitle) + .authFont(.largeTitle) .fontWeight(.bold) .multilineTextAlignment(.center) Text("Manage your authentication methods") - .font(.subheadline) + .authFont(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -86,13 +86,13 @@ extension MFAManagementView: View { .foregroundColor(.orange) Text("No Authentication Methods") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text( "Set up two-factor authentication to add an extra layer of security to your account." ) - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) @@ -113,7 +113,7 @@ extension MFAManagementView: View { // Show enrolled factors VStack(alignment: .leading, spacing: 16) { Text("Enrolled Methods") - .font(.headline) + .authFont(.headline) .padding(.horizontal) ForEach(enrolledFactors) { factor in @@ -156,25 +156,25 @@ extension MFAManagementView: View { .foregroundColor(.green) } } - .font(.title2) + .authFont(.title2) VStack(alignment: .leading, spacing: 4) { Text(factor.displayName ?? authService.string.unnamedMethodLabel) - .font(.headline) + .authFont(.headline) if factor.factorID == PhoneMultiFactorID { let phoneInfo = factor as! PhoneMultiFactorInfo Text("SMS: \(phoneInfo.phoneNumber)") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } else { Text("Authenticator App") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } Text("Enrolled: \(DateFormatter.shortDate.string(from: factor.enrollmentDate))") - .font(.caption2) + .authFont(.caption2) .foregroundColor(.secondary) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift index f6e88f94ea..cf47658ccf 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift @@ -13,6 +13,7 @@ // limitations under the License. import FirebaseAuth +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -114,12 +115,12 @@ extension MFAResolutionView: View { .foregroundColor(.blue) Text("Two-Factor Authentication") - .font(.largeTitle) + .authFont(.largeTitle) .fontWeight(.bold) .accessibilityIdentifier("mfa-resolution-title") Text("Complete sign-in with your second factor") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -145,6 +146,7 @@ extension MFAResolutionView: View { .scaleEffect(0.8) } Text("Complete Sign-In") + .authFont(.body) } .frame(maxWidth: .infinity) .padding() @@ -158,6 +160,7 @@ extension MFAResolutionView: View { // Cancel Button Button(action: cancelResolution) { Text("Cancel") + .authFont(.body) .frame(maxWidth: .infinity) .padding() .background(Color.gray.opacity(0.2)) @@ -190,16 +193,16 @@ extension MFAResolutionView: View { .foregroundColor(.blue) Text("SMS Verification") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) if let phoneNumber = phoneNumber { Text("We'll send a code to ••••••\(String(phoneNumber.suffix(4)))") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) } else { Text("We'll send a verification code to your phone") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) } } @@ -214,6 +217,7 @@ extension MFAResolutionView: View { .scaleEffect(0.8) } Text("Send Code") + .authFont(.body) } .frame(maxWidth: .infinity) .padding() @@ -228,7 +232,7 @@ extension MFAResolutionView: View { // Verification code input VStack(alignment: .leading, spacing: 8) { Text("Verification Code") - .font(.headline) + .authFont(.headline) TextField("Enter 6-digit code", text: $verificationCode) .textFieldStyle(RoundedBorderTextFieldStyle()) @@ -250,17 +254,17 @@ extension MFAResolutionView: View { .foregroundColor(.green) Text("Authenticator App") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) Text("Enter the 6-digit code from your authenticator app") - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) if let displayName = displayName { Text(authService.string.accountPrefix(displayName: displayName)) - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } } @@ -269,7 +273,7 @@ extension MFAResolutionView: View { // TOTP code input VStack(alignment: .leading, spacing: 8) { Text("Verification Code") - .font(.headline) + .authFont(.headline) TextField("Enter 6-digit code", text: $totpCode) .textFieldStyle(RoundedBorderTextFieldStyle()) @@ -285,7 +289,7 @@ extension MFAResolutionView: View { private func mfaHintsSelectionView(mfaRequired: MFARequired) -> some View { VStack(alignment: .leading, spacing: 12) { Text("Choose verification method:") - .font(.headline) + .authFont(.headline) .padding(.horizontal) // More idiomatic approach using indices @@ -311,7 +315,7 @@ extension MFAResolutionView: View { VStack(alignment: .leading) { Text(hintDisplayName(for: hint)) - .font(.body) + .authFont(.body) .foregroundColor(.primary) hintSubtitle(for: hint) @@ -345,7 +349,7 @@ extension MFAResolutionView: View { private func hintSubtitle(for hint: MFAHint) -> some View { if case let .phone(_, _, phoneNumber) = hint, let phone = phoneNumber { Text("••••••\(String(phone.suffix(4)))") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift index 80285bba2e..7e28ea2dee 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift @@ -78,15 +78,17 @@ extension PasswordRecoveryView: View { private var successSheet: some View { VStack { Text(authService.string.passwordRecoveryEmailSentTitle) - .font(.largeTitle) + .authFont(.largeTitle) .fontWeight(.bold) .padding() Text(authService.string.passwordRecoveryHelperMessage) + .authFont(.body) .padding() Divider() Text(String(format: authService.string.passwordRecoveryEmailSentMessage, sentEmail)) + .authFont(.body) .padding() Divider() diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift index df2ca7f894..deed8e5799 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift @@ -91,11 +91,11 @@ extension PhoneReauthView: View { .foregroundColor(.blue) Text(authService.string.verifyPhoneNumberTitle) - .font(.title) + .authFont(.title) .fontWeight(.bold) Text(authService.string.forSecurityVerifyPhoneMessage) - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) } @@ -105,12 +105,12 @@ extension PhoneReauthView: View { // Initial state - sending SMS VStack(spacing: 20) { Text(authService.string.sendVerificationCodeToPhonePrefix) - .font(.subheadline) + .authFont(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) Text(phoneNumber) - .font(.headline) + .authFont(.headline) .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 8) @@ -139,12 +139,12 @@ extension PhoneReauthView: View { // Enter verification code VStack(spacing: 20) { Text(authService.string.enterSixDigitCodeSentToPrefix) - .font(.subheadline) + .authFont(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) Text(phoneNumber) - .font(.caption) + .authFont(.caption) .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 8) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift index f5e6bfb638..e4dc34c74f 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift @@ -18,6 +18,7 @@ // // Created by Russell Wheatley on 12/05/2025. // +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -66,6 +67,7 @@ extension PrivacyTOCsView: View { if let tosURL = authService.configuration.tosUrl, let privacyURL = authService.configuration.privacyPolicyUrl { Text(attributedMessage(tosURL: tosURL, privacyURL: privacyURL)) + .authFont(.body) .multilineTextAlignment(displayMode == .full ? .center : .trailing) .padding() } else { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift index 759481a261..cbf5b9c7f8 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift @@ -41,13 +41,14 @@ extension SignedInView: View { public var body: some View { VStack { Text(authService.string.signedInTitle) - .font(.largeTitle) + .authFont(.largeTitle) .fontWeight(.bold) .padding() .accessibilityIdentifier("signed-in-text") Text( "\(authService.currentUser?.email ?? authService.currentUser?.displayName ?? authService.currentUser?.phoneNumber ?? "")" ) + .authFont(.body) if authService.currentUser?.isEmailVerified == false { Button { Task { @@ -178,13 +179,13 @@ private struct DeleteAccountConfirmationSheet: View { .foregroundColor(.red) Text("Delete Account?") - .font(.title) + .authFont(.title) .fontWeight(.bold) Text( "This action cannot be undone. All your data will be permanently deleted. You may need to reauthenticate to complete this action." ) - .font(.body) + .authFont(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) @@ -208,6 +209,7 @@ private struct DeleteAccountConfirmationSheet: View { onCancel() } label: { Text("Cancel") + .authFont(.body) .padding(.vertical, 8) .frame(maxWidth: .infinity) } diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift index f8df18d14d..1dbd1e7138 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift @@ -42,6 +42,7 @@ public struct AuthProviderButton: View { providerIcon(for: icon, tint: style.iconTint) } Text(label) + .authFont(.body) .lineLimit(1) .truncationMode(.tail) .foregroundStyle(style.contentColor) diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift index 636ad44dd8..8151340c8a 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift @@ -16,6 +16,7 @@ import SwiftUI public struct AuthTextField: View { @Environment(\.authTextFieldStyle) private var style + @Environment(\.authTypography) private var typography @FocusState private var isFocused: Bool @State var obscured: Bool = true @State var hasInteracted: Bool = false @@ -69,16 +70,17 @@ public struct AuthTextField: View { public var body: some View { VStack(alignment: .leading) { Text(LocalizedStringResource(stringLiteral: label)) + .authFont(.body) HStack(spacing: 8) { leading() Group { if isSecureTextField { ZStack(alignment: .trailing) { - SecureField(label, text: $text, prompt: Text(prompt)) + SecureField(label, text: $text, prompt: Text(prompt).font(typography.resolvedFont(for: .body))) .opacity(obscured ? 1 : 0) .focused($isFocused) .frame(height: 24) - TextField(label, text: $text, prompt: Text(prompt)) + TextField(label, text: $text, prompt: Text(prompt).font(typography.resolvedFont(for: .body))) .opacity(obscured ? 0 : 1) .focused($isFocused) .frame(height: 24) @@ -101,7 +103,7 @@ public struct AuthTextField: View { TextField( label, text: $text, - prompt: Text(prompt) + prompt: Text(prompt).font(typography.resolvedFont(for: .body)) ) .frame(height: 24) } @@ -153,7 +155,7 @@ public struct AuthTextField: View { ForEach(validations) { validator in let isValid = validator.isValid(input: text) Text(validator.message) - .font(.caption) + .authFont(.caption) .strikethrough(isValid, color: style.secondaryColor ?? .gray) .foregroundStyle(isValid ? (style.secondaryColor ?? .gray) : (style.errorColor ?? .red)) .fixedSize(horizontal: false, vertical: true) diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift index 050d3c37ce..c908927d64 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift @@ -87,18 +87,19 @@ public struct CountrySelector: View { selectedCountry = country } label: { Text("\(country.flag) \(country.name) (\(country.dialCode))") + .authFont(.body) } .accessibilityIdentifier("country-option-\(country.code)") } } label: { HStack(spacing: 4) { Text(selectedCountry.flag) - .font(.title3) + .authFont(.title3) Text(selectedCountry.dialCode) - .font(.body) + .authFont(.body) .foregroundStyle(.primary) Image(systemName: "chevron.down") - .font(.caption2) + .authFont(.caption2) .foregroundStyle(.secondary) } } diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift index cc226a33c3..06eced331f 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift @@ -91,7 +91,7 @@ public struct VerificationCodeInputField: View { if isError, let errorMessage = errorMessage { Text(errorMessage) - .font(.caption) + .authFont(.caption) .foregroundColor(.red) .frame(maxWidth: .infinity, alignment: .leading) } @@ -102,7 +102,7 @@ public struct VerificationCodeInputField: View { ForEach(validations) { validator in let isValid = validator.isValid(input: code) Text(validator.message) - .font(.caption) + .authFont(.caption) .strikethrough(isValid, color: .gray) .foregroundStyle(isValid ? .gray : .red) .fixedSize(horizontal: false, vertical: true) @@ -505,7 +505,7 @@ private final class BackspaceUITextField: UITextField { return VStack(spacing: 32) { Text("Enter Verification Code") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) VerificationCodeInputField( @@ -519,7 +519,7 @@ private final class BackspaceUITextField: UITextField { ) Text("Current code: \(code)") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } .padding() @@ -530,7 +530,7 @@ private final class BackspaceUITextField: UITextField { return VStack(spacing: 32) { Text("Enter Verification Code") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) VerificationCodeInputField( @@ -546,7 +546,7 @@ private final class BackspaceUITextField: UITextField { ) Text("Current code: \(code)") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } .padding() @@ -557,7 +557,7 @@ private final class BackspaceUITextField: UITextField { return VStack(spacing: 32) { Text("Enter 4-Digit Code") - .font(.title2) + .authFont(.title2) .fontWeight(.semibold) VerificationCodeInputField( @@ -572,7 +572,7 @@ private final class BackspaceUITextField: UITextField { ) Text("Current code: \(code)") - .font(.caption) + .authFont(.caption) .foregroundColor(.secondary) } .padding() diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift new file mode 100644 index 0000000000..8578d311f5 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift @@ -0,0 +1,101 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SwiftUI + +/// Typography configuration for the auth flow. `fontFamily` defaults to `nil`, in which case +/// text keeps using the system font at each semantic text style, exactly as it does today. +public struct AuthTypography: Sendable { + /// The PostScript name of a custom font registered with the app (e.g. via Info.plist). + public var fontFamily: String? + + public init(fontFamily: String? = nil) { + self.fontFamily = fontFamily + } + + public static let `default` = AuthTypography() + + /// Resolves a semantic text style against this typography's `fontFamily`, preserving Dynamic + /// Type scaling relative to `style`. Falls back to the system font when `fontFamily` is unset. + public func resolvedFont(for style: Font.TextStyle, weight: Font.Weight? = nil) -> Font { + let base: Font = if let fontFamily { + .custom(fontFamily, size: UIFont.preferredFont(forTextStyle: style.uiKit).pointSize, relativeTo: style) + } else { + .system(style) + } + return weight.map { base.weight($0) } ?? base + } +} + +private struct AuthTypographyKey: EnvironmentKey { + static let defaultValue: AuthTypography = .default +} + +public extension EnvironmentValues { + var authTypography: AuthTypography { + get { self[AuthTypographyKey.self] } + set { self[AuthTypographyKey.self] = newValue } + } +} + +public extension View { + /// Sets the custom font family used by every semantic text style (`.headline`, `.body`, + /// `.caption`, etc.) throughout the auth flow, while preserving Dynamic Type scaling relative + /// to each style. + /// + /// ```swift + /// AuthPickerView { ... } + /// .authTypography(AuthTypography(fontFamily: "Poppins-Regular")) + /// ``` + func authTypography(_ typography: AuthTypography) -> some View { + environment(\.authTypography, typography) + } +} + +public struct AuthFontModifier: ViewModifier { + @Environment(\.authTypography) private var typography + let style: Font.TextStyle + var weight: Font.Weight? + + public func body(content: Content) -> some View { + content.font(typography.resolvedFont(for: style, weight: weight)) + } +} + +public extension View { + /// Applies a semantic text style, resolved against the environment's ``AuthTypography`` — + /// use in place of a bare `.font(.headline)`/`.font(.caption)`/etc. call. + func authFont(_ style: Font.TextStyle, weight: Font.Weight? = nil) -> some View { + modifier(AuthFontModifier(style: style, weight: weight)) + } +} + +private extension Font.TextStyle { + var uiKit: UIFont.TextStyle { + switch self { + case .largeTitle: .largeTitle + case .title: .title1 + case .title2: .title2 + case .title3: .title3 + case .headline: .headline + case .subheadline: .subheadline + case .body: .body + case .callout: .callout + case .footnote: .footnote + case .caption: .caption1 + case .caption2: .caption2 + @unknown default: .body + } + } +} From 3df38020969b684424409185e226eeeed193a635 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:33:12 +0100 Subject: [PATCH 04/25] fix(auth): apply AuthTypography to typed text and the authenticating overlay --- .../FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift | 1 + .../Sources/Components/AuthTextField.swift | 1 + .../Sources/Components/VerificationCodeInputField.swift | 4 +++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 8454c79d8b..220ee80adc 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -128,6 +128,7 @@ extension AuthPickerView: View { .scaleEffect(1.25) .tint(.white) Text("Authenticating...") + .authFont(.body) .foregroundStyle(.white) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift index 8151340c8a..0bc5627269 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift @@ -108,6 +108,7 @@ public struct AuthTextField: View { .frame(height: 24) } } + .authFont(.body) } .frame(maxWidth: .infinity) .keyboardType(keyboardType) diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift index 06eced331f..8d7fb4d2be 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift @@ -293,6 +293,7 @@ public struct VerificationCodeInputField: View { } private struct SingleDigitField: View { + @Environment(\.authTypography) private var typography @Binding var digit: String let isError: Bool let isFocused: Bool @@ -331,7 +332,8 @@ private struct SingleDigitField: View { }, maxCharacters: maxDigits, configuration: { textField in - textField.font = .systemFont(ofSize: 24, weight: .medium) + textField.font = typography.fontFamily.flatMap { UIFont(name: $0, size: 24) } + ?? .systemFont(ofSize: 24, weight: .medium) textField.textAlignment = .center textField.keyboardType = .numberPad textField.textContentType = .oneTimeCode From a552c15cd7a500e5233c3849ebc12e702d59558f Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:01:56 +0100 Subject: [PATCH 05/25] feat(auth): re-export FirebaseAuthUIComponents from FirebaseAuthSwiftUI --- .../FirebaseAuthSwiftUI/Sources/Exports.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Exports.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Exports.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Exports.swift new file mode 100644 index 0000000000..fdc5d9c91e --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Exports.swift @@ -0,0 +1,17 @@ +// 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. + +// FirebaseAuthUIComponents is not a library product, so re-export it to make +// AuthTextFieldStyle and AuthTypography reachable from `import FirebaseAuthSwiftUI`. +@_exported import FirebaseAuthUIComponents From da943d31aec529367d1df428beea788640ef1b92 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:18:24 +0100 Subject: [PATCH 06/25] fix(auth): avoid double Dynamic Type scaling for custom font families --- .../Sources/Theme/AuthTypography.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift index 8578d311f5..02b245770a 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift @@ -29,8 +29,17 @@ public struct AuthTypography: Sendable { /// Resolves a semantic text style against this typography's `fontFamily`, preserving Dynamic /// Type scaling relative to `style`. Falls back to the system font when `fontFamily` is unset. public func resolvedFont(for style: Font.TextStyle, weight: Font.Weight? = nil) -> Font { + // Use the style's size at the default content size category; `relativeTo:` applies the + // user's Dynamic Type scaling, so an already-scaled size would be scaled twice. let base: Font = if let fontFamily { - .custom(fontFamily, size: UIFont.preferredFont(forTextStyle: style.uiKit).pointSize, relativeTo: style) + .custom( + fontFamily, + size: UIFont.preferredFont( + forTextStyle: style.uiKit, + compatibleWith: UITraitCollection(preferredContentSizeCategory: .large) + ).pointSize, + relativeTo: style + ) } else { .system(style) } From 840a7550d9cfede94f2cfc721058c6f925e5430a Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:55:49 +0100 Subject: [PATCH 07/25] fix(auth): address review findings on theming defaults and API surface - CTA labels fall back to body, not headline, so unthemed buttons keep their weight - Provider buttons keep the system font; Delete Account stays red under a CTA theme - Make the no-argument authCTAButtonStyle() and AuthFontModifier internal - Rename AuthTypography.fontFamily to fontName - Re-apply the custom font to verification code boxes on update - Move MFAResolutionView onto VerificationCodeInputField and shared button styles --- .../Sources/Views/AuthCTAButtonModifier.swift | 6 ++- .../Sources/Views/MFAResolutionView.swift | 49 +++++++------------ .../Sources/Views/SignedInView.swift | 3 +- .../Components/AuthProviderButton.swift | 1 - .../VerificationCodeInputField.swift | 9 +++- .../Sources/Theme/AuthTypography.swift | 24 ++++----- .../MFAResolutionUITests.swift | 14 ++++-- 7 files changed, 53 insertions(+), 53 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift index 89f2a4ec5a..568958e8e5 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift @@ -80,18 +80,20 @@ private struct CTAFontModifier: ViewModifier { if let explicitFont { content.font(explicitFont) } else { - content.authFont(.headline) + content.authFont(.body) } } } -public extension View { +extension View { /// Applies the auth flow's primary call-to-action button styling, reading colors/shape/font /// from the environment's ``AuthCTAButtonStyle`` (set via `.authCTAButtonStyle(_:)`). func authCTAButtonStyle() -> some View { modifier(AuthCTAButtonModifier()) } +} +public extension View { /// Sets the ``AuthCTAButtonStyle`` used by every CTA button in this view's subtree. /// /// ```swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift index cf47658ccf..722b4ee825 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift @@ -17,11 +17,6 @@ import FirebaseAuthUIComponents import FirebaseCore import SwiftUI -private enum FocusableField: Hashable { - case verificationCode - case totpCode -} - @MainActor public struct MFAResolutionView { let mfaRequired: MFARequired @@ -35,8 +30,6 @@ public struct MFAResolutionView { @State private var selectedHintIndex = 0 @State private var verificationId: String? - @FocusState private var focus: FocusableField? - public init(mfaRequired: MFARequired) { self.mfaRequired = mfaRequired } @@ -146,14 +139,11 @@ extension MFAResolutionView: View { .scaleEffect(0.8) } Text("Complete Sign-In") - .authFont(.body) } + .padding(.vertical, 8) .frame(maxWidth: .infinity) - .padding() - .background(canCompleteResolution ? Color.blue : Color.gray) - .foregroundColor(.white) - .cornerRadius(8) } + .authCTAButtonStyle() .disabled(!canCompleteResolution) .accessibilityIdentifier("complete-resolution-button") @@ -161,12 +151,10 @@ extension MFAResolutionView: View { Button(action: cancelResolution) { Text("Cancel") .authFont(.body) + .padding(.vertical, 8) .frame(maxWidth: .infinity) - .padding() - .background(Color.gray.opacity(0.2)) - .foregroundColor(.primary) - .cornerRadius(8) } + .buttonStyle(.bordered) .accessibilityIdentifier("cancel-button") } .padding(.horizontal) @@ -217,14 +205,11 @@ extension MFAResolutionView: View { .scaleEffect(0.8) } Text("Send Code") - .authFont(.body) } + .padding(.vertical, 8) .frame(maxWidth: .infinity) - .padding() - .background(isLoading ? Color.gray : Color.blue) - .foregroundColor(.white) - .cornerRadius(8) } + .authCTAButtonStyle() .disabled(isLoading) .padding(.horizontal) .accessibilityIdentifier("send-sms-button") @@ -234,11 +219,12 @@ extension MFAResolutionView: View { Text("Verification Code") .authFont(.headline) - TextField("Enter 6-digit code", text: $verificationCode) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .keyboardType(.numberPad) - .focused($focus, equals: .verificationCode) - .accessibilityIdentifier("sms-verification-code-field") + VerificationCodeInputField( + code: $verificationCode, + validations: [FormValidators.verificationCode], + maintainsValidationMessage: true + ) + .accessibilityIdentifier("sms-verification-code-field") } .padding(.horizontal) } @@ -275,11 +261,12 @@ extension MFAResolutionView: View { Text("Verification Code") .authFont(.headline) - TextField("Enter 6-digit code", text: $totpCode) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .keyboardType(.numberPad) - .focused($focus, equals: .totpCode) - .accessibilityIdentifier("totp-verification-code-field") + VerificationCodeInputField( + code: $totpCode, + validations: [FormValidators.verificationCode], + maintainsValidationMessage: true + ) + .accessibilityIdentifier("totp-verification-code-field") } .padding(.horizontal) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift index cbf5b9c7f8..6e6da427f6 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift @@ -196,10 +196,11 @@ private struct DeleteAccountConfirmationSheet: View { onConfirm() } label: { Text("Delete Account") + .authFont(.body) .padding(.vertical, 8) .frame(maxWidth: .infinity) } - .authCTAButtonStyle() + .buttonStyle(.borderedProminent) .tint(.red) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift index 1dbd1e7138..f8df18d14d 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift @@ -42,7 +42,6 @@ public struct AuthProviderButton: View { providerIcon(for: icon, tint: style.iconTint) } Text(label) - .authFont(.body) .lineLimit(1) .truncationMode(.tail) .foregroundStyle(style.contentColor) diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift index 8d7fb4d2be..bb714bc24d 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift @@ -331,9 +331,9 @@ private struct SingleDigitField: View { onFocusChanged(isFocused) }, maxCharacters: maxDigits, + font: typography.fontName.flatMap { UIFont(name: $0, size: 24) } + ?? .systemFont(ofSize: 24, weight: .medium), configuration: { textField in - textField.font = typography.fontFamily.flatMap { UIFont(name: $0, size: 24) } - ?? .systemFont(ofSize: 24, weight: .medium) textField.textAlignment = .center textField.keyboardType = .numberPad textField.textContentType = .oneTimeCode @@ -369,6 +369,7 @@ private struct BackspaceAwareTextField: UIViewRepresentable { let onDeleteBackwardWhenEmpty: () -> Void let onFocusChanged: (Bool) -> Void let maxCharacters: Int + let font: UIFont let configuration: (UITextField) -> Void let onTextChange: (String) -> Void @@ -382,6 +383,7 @@ private struct BackspaceAwareTextField: UIViewRepresentable { for: .editingChanged ) configuration(textField) + textField.font = font textField.onDeleteBackward = { [weak textField] in guard let textField else { return } if (textField.text ?? "").isEmpty { @@ -396,6 +398,9 @@ private struct BackspaceAwareTextField: UIViewRepresentable { if uiView.text != text { uiView.text = text } + if uiView.font != font { + uiView.font = font + } uiView.onDeleteBackward = { [weak uiView] in guard let uiView else { return } diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift index 02b245770a..adbf3dd709 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift @@ -14,26 +14,26 @@ import SwiftUI -/// Typography configuration for the auth flow. `fontFamily` defaults to `nil`, in which case +/// Typography configuration for the auth flow. `fontName` defaults to `nil`, in which case /// text keeps using the system font at each semantic text style, exactly as it does today. public struct AuthTypography: Sendable { /// The PostScript name of a custom font registered with the app (e.g. via Info.plist). - public var fontFamily: String? + public var fontName: String? - public init(fontFamily: String? = nil) { - self.fontFamily = fontFamily + public init(fontName: String? = nil) { + self.fontName = fontName } public static let `default` = AuthTypography() - /// Resolves a semantic text style against this typography's `fontFamily`, preserving Dynamic - /// Type scaling relative to `style`. Falls back to the system font when `fontFamily` is unset. + /// Resolves a semantic text style against this typography's `fontName`, preserving Dynamic + /// Type scaling relative to `style`. Falls back to the system font when `fontName` is unset. public func resolvedFont(for style: Font.TextStyle, weight: Font.Weight? = nil) -> Font { // Use the style's size at the default content size category; `relativeTo:` applies the // user's Dynamic Type scaling, so an already-scaled size would be scaled twice. - let base: Font = if let fontFamily { + let base: Font = if let fontName { .custom( - fontFamily, + fontName, size: UIFont.preferredFont( forTextStyle: style.uiKit, compatibleWith: UITraitCollection(preferredContentSizeCategory: .large) @@ -59,25 +59,25 @@ public extension EnvironmentValues { } public extension View { - /// Sets the custom font family used by every semantic text style (`.headline`, `.body`, + /// Sets the custom font used by every semantic text style (`.headline`, `.body`, /// `.caption`, etc.) throughout the auth flow, while preserving Dynamic Type scaling relative /// to each style. /// /// ```swift /// AuthPickerView { ... } - /// .authTypography(AuthTypography(fontFamily: "Poppins-Regular")) + /// .authTypography(AuthTypography(fontName: "Poppins-Regular")) /// ``` func authTypography(_ typography: AuthTypography) -> some View { environment(\.authTypography, typography) } } -public struct AuthFontModifier: ViewModifier { +struct AuthFontModifier: ViewModifier { @Environment(\.authTypography) private var typography let style: Font.TextStyle var weight: Font.Weight? - public func body(content: Content) -> some View { + func body(content: Content) -> some View { content.font(typography.resolvedFont(for: style, weight: weight)) } } diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift index 9024a19f83..4f117bceb5 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift @@ -93,10 +93,16 @@ final class MFAResolutionUITests: XCTestCase { return } - let codeField = app.textFields["sms-verification-code-field"] - XCTAssertTrue(codeField.waitForExistence(timeout: 10), "Code field should exist") - codeField.tap() - codeField.typeText(verificationCode) + let firstDigitField = app.otherElements["Digit 1 of 6"].textFields.firstMatch + XCTAssertTrue(firstDigitField.waitForExistence(timeout: 10), "Code field should exist") + // Paste each digit into its own box, matching MFAEnrolmentUITests. + for (index, digit) in verificationCode.enumerated() { + let field = app.otherElements["Digit \(index + 1) of 6"].textFields.firstMatch + UIPasteboard.general.string = String(digit) + field.tap() + field.press(forDuration: 1.2) + app.menuItems["Paste"].tap() + } let completeButton = app.buttons["complete-resolution-button"] XCTAssertTrue(completeButton.exists, "Complete button should exist") From 9cea9863ffa37f0ea7d826e3348c3401fa3e9857 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:11:07 +0100 Subject: [PATCH 08/25] fix(auth): apply AuthTextFieldStyle to the verification code input --- .../VerificationCodeInputField.swift | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift index bb714bc24d..b9971494a0 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift @@ -35,6 +35,7 @@ public struct VerificationCodeInputField: View { _digitFields = State(initialValue: Array(repeating: "", count: codeLength)) } + @Environment(\.authTextFieldStyle) private var style @Binding var code: String let codeLength: Int let isError: Bool @@ -92,7 +93,7 @@ public struct VerificationCodeInputField: View { if isError, let errorMessage = errorMessage { Text(errorMessage) .authFont(.caption) - .foregroundColor(.red) + .foregroundStyle(style.errorColor ?? .red) .frame(maxWidth: .infinity, alignment: .leading) } @@ -103,8 +104,8 @@ public struct VerificationCodeInputField: View { let isValid = validator.isValid(input: code) Text(validator.message) .authFont(.caption) - .strikethrough(isValid, color: .gray) - .foregroundStyle(isValid ? .gray : .red) + .strikethrough(isValid, color: style.secondaryColor ?? .gray) + .foregroundStyle(isValid ? (style.secondaryColor ?? .gray) : (style.errorColor ?? .red)) .fixedSize(horizontal: false, vertical: true) } } @@ -294,6 +295,7 @@ public struct VerificationCodeInputField: View { private struct SingleDigitField: View { @Environment(\.authTypography) private var typography + @Environment(\.authTextFieldStyle) private var style @Binding var digit: String let isError: Bool let isFocused: Bool @@ -311,9 +313,9 @@ private struct SingleDigitField: View { } private var borderColor: Color { - if isError { return .red } - if isFocused || !digit.isEmpty { return .accentColor } - return Color(.systemFill) + if isError { return style.errorColor ?? .red } + if isFocused || !digit.isEmpty { return style.tint ?? .accentColor } + return style.containerColor ?? Color(.systemFill) } var body: some View { @@ -346,10 +348,10 @@ private struct SingleDigitField: View { ) .frame(width: 48, height: 48) .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color.accentColor.opacity(0.05)) + RoundedRectangle(cornerRadius: style.cornerRadius ?? 8) + .fill((style.tint ?? Color.accentColor).opacity(0.05)) .overlay( - RoundedRectangle(cornerRadius: 8) + RoundedRectangle(cornerRadius: style.cornerRadius ?? 8) .stroke(borderColor, lineWidth: borderWidth) ) ) From 523dbdc898f0991c52fe92954530f227bea91b66 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:41:19 +0100 Subject: [PATCH 09/25] fix(auth): leave inherited font, shape and tint alone when no theme is set --- .../Sources/Views/AuthCTAButtonModifier.swift | 40 +++++++++++++++---- .../Sources/Views/AuthPickerView.swift | 2 +- .../Sources/Views/EmailAuthView.swift | 6 +-- .../Sources/Views/EmailLinkReauthView.swift | 4 +- .../Views/LegacySignInRecoveryView.swift | 2 +- .../Sources/Views/MFAEnrolmentView.swift | 2 +- .../Sources/Views/MFAResolutionView.swift | 2 +- .../Sources/Views/PasswordRecoveryView.swift | 4 +- .../Sources/Views/PrivacyTOCsView.swift | 2 +- .../Sources/Views/SignedInView.swift | 6 +-- .../Sources/Components/AuthTextField.swift | 16 +++++--- .../Sources/Components/CountrySelector.swift | 2 +- .../Sources/Theme/AuthTypography.swift | 23 +++++++++++ 13 files changed, 83 insertions(+), 28 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift index 568958e8e5..ad602136ce 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthCTAButtonModifier.swift @@ -51,10 +51,9 @@ public extension EnvironmentValues { } /// Applies `.buttonStyle(.borderedProminent)` plus the colors/shape/font from the environment's -/// ``AuthCTAButtonStyle``, in place of a bare `.buttonStyle(.borderedProminent)` call. When -/// `style.font` is left unset, the button falls back to ``AuthTypography`` (via `.authFont(_:)`) -/// so it stays consistent with the rest of the auth flow's typography by default — an explicit -/// `style.font` still overrides that, for buttons that should intentionally look different. +/// ``AuthCTAButtonStyle``, in place of a bare `.buttonStyle(.borderedProminent)` call. Unset +/// values leave the inherited shape, tint and font alone. When `style.font` is unset, the label +/// uses ``AuthTypography``'s custom font if one is set. struct AuthCTAButtonModifier: ViewModifier { @Environment(\.authCTAButtonStyle) private var style @@ -67,12 +66,39 @@ struct AuthCTAButtonModifier: ViewModifier { } } .buttonStyle(.borderedProminent) - .buttonBorderShape(style.shape ?? .automatic) - .tint(style.backgroundColor) + .modifier(CTAShapeModifier(shape: style.shape)) + .modifier(CTATintModifier(tint: style.backgroundColor)) .modifier(CTAFontModifier(explicitFont: style.font)) } } +// Each modifier below only applies a value when one is set, so an unset style leaves whatever +// the app set higher up (shape, tint, font) in effect, as before theming. + +private struct CTAShapeModifier: ViewModifier { + let shape: ButtonBorderShape? + + func body(content: Content) -> some View { + if let shape { + content.buttonBorderShape(shape) + } else { + content + } + } +} + +private struct CTATintModifier: ViewModifier { + let tint: Color? + + func body(content: Content) -> some View { + if let tint { + content.tint(tint) + } else { + content + } + } +} + private struct CTAFontModifier: ViewModifier { let explicitFont: Font? @@ -80,7 +106,7 @@ private struct CTAFontModifier: ViewModifier { if let explicitFont { content.font(explicitFont) } else { - content.authFont(.body) + content.authCustomFont(.body) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 220ee80adc..28fbe389d8 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -128,7 +128,7 @@ extension AuthPickerView: View { .scaleEffect(1.25) .tint(.white) Text("Authenticating...") - .authFont(.body) + .authCustomFont(.body) .foregroundStyle(.white) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index 5f59199573..7d06240f4a 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -150,7 +150,7 @@ extension EmailAuthView: View { authService.navigator.push(.passwordRecovery) } label: { Text(authService.string.passwordButtonLabel) - .authFont(.body, weight: .medium) + .authCustomFont(.body, weight: .medium) .frame(maxWidth: .infinity, alignment: .trailing) } .accessibilityIdentifier("password-recovery-button") @@ -223,14 +223,14 @@ extension EmailAuthView: View { ? authService.string.dontHaveAnAccountYetLabel : authService.string.alreadyHaveAnAccountLabel ) - .authFont(.body) + .authCustomFont(.body) .foregroundStyle(Color(.label)) Text( authService.authenticationFlow == .signUp ? authService.string.emailLoginFlowLabel : authService.string.emailSignUpFlowLabel ) - .authFont(.body, weight: .medium) + .authCustomFont(.body, weight: .medium) } } .accessibilityIdentifier("switch-auth-flow") diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift index 04f5ab8c54..fc896270f9 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift @@ -111,7 +111,7 @@ extension EmailLinkReauthView: View { .frame(height: 32) } else { Text("Resend Email") - .authFont(.body) + .authCustomFont(.body) .frame(height: 32) } } @@ -125,7 +125,7 @@ extension EmailLinkReauthView: View { ProgressView() .padding(.top, 32) Text("Sending verification email...") - .authFont(.body) + .authCustomFont(.body) .foregroundStyle(.secondary) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/LegacySignInRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/LegacySignInRecoveryView.swift index d49a600037..5ece47a703 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/LegacySignInRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/LegacySignInRecoveryView.swift @@ -29,7 +29,7 @@ struct LegacySignInRecoveryView: View { Text(authService.string.legacySignInRecoveryTitle) .authFont(.title2, weight: .semibold) Text(authService.string.legacySignInRecoveryMessage(email: recovery.email)) - .authFont(.body) + .authCustomFont(.body) .foregroundStyle(.secondary) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift index c5928223f8..7405ab1db1 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift @@ -490,7 +490,7 @@ extension MFAEnrolmentView: View { sendSMSVerification() } label: { Text("Resend Code") - .authFont(.body) + .authCustomFont(.body) .padding(.vertical, 8) .frame(maxWidth: .infinity) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift index 722b4ee825..e3dd9a28c2 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift @@ -150,7 +150,7 @@ extension MFAResolutionView: View { // Cancel Button Button(action: cancelResolution) { Text("Cancel") - .authFont(.body) + .authCustomFont(.body) .padding(.vertical, 8) .frame(maxWidth: .infinity) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift index 7e28ea2dee..73a60758e6 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift @@ -82,13 +82,13 @@ extension PasswordRecoveryView: View { .fontWeight(.bold) .padding() Text(authService.string.passwordRecoveryHelperMessage) - .authFont(.body) + .authCustomFont(.body) .padding() Divider() Text(String(format: authService.string.passwordRecoveryEmailSentMessage, sentEmail)) - .authFont(.body) + .authCustomFont(.body) .padding() Divider() diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift index e4dc34c74f..629532a823 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift @@ -67,7 +67,7 @@ extension PrivacyTOCsView: View { if let tosURL = authService.configuration.tosUrl, let privacyURL = authService.configuration.privacyPolicyUrl { Text(attributedMessage(tosURL: tosURL, privacyURL: privacyURL)) - .authFont(.body) + .authCustomFont(.body) .multilineTextAlignment(displayMode == .full ? .center : .trailing) .padding() } else { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift index 6e6da427f6..45ebffb82e 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift @@ -48,7 +48,7 @@ extension SignedInView: View { Text( "\(authService.currentUser?.email ?? authService.currentUser?.displayName ?? authService.currentUser?.phoneNumber ?? "")" ) - .authFont(.body) + .authCustomFont(.body) if authService.currentUser?.isEmailVerified == false { Button { Task { @@ -196,7 +196,7 @@ private struct DeleteAccountConfirmationSheet: View { onConfirm() } label: { Text("Delete Account") - .authFont(.body) + .authCustomFont(.body) .padding(.vertical, 8) .frame(maxWidth: .infinity) } @@ -210,7 +210,7 @@ private struct DeleteAccountConfirmationSheet: View { onCancel() } label: { Text("Cancel") - .authFont(.body) + .authCustomFont(.body) .padding(.vertical, 8) .frame(maxWidth: .infinity) } diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift index 0bc5627269..61cdb6b57a 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift @@ -63,6 +63,12 @@ public struct AuthTextField: View { self.leading = leading } + /// The placeholder uses the custom font only when one is set, so it otherwise inherits the + /// field's font as before theming. + private var promptText: Text { + typography.fontName == nil ? Text(prompt) : Text(prompt).font(typography.resolvedFont(for: .body)) + } + var allRequirementsMet: Bool { validations.allSatisfy { $0.isValid(input: text) } } @@ -70,17 +76,17 @@ public struct AuthTextField: View { public var body: some View { VStack(alignment: .leading) { Text(LocalizedStringResource(stringLiteral: label)) - .authFont(.body) + .authCustomFont(.body) HStack(spacing: 8) { leading() Group { if isSecureTextField { ZStack(alignment: .trailing) { - SecureField(label, text: $text, prompt: Text(prompt).font(typography.resolvedFont(for: .body))) + SecureField(label, text: $text, prompt: promptText) .opacity(obscured ? 1 : 0) .focused($isFocused) .frame(height: 24) - TextField(label, text: $text, prompt: Text(prompt).font(typography.resolvedFont(for: .body))) + TextField(label, text: $text, prompt: promptText) .opacity(obscured ? 0 : 1) .focused($isFocused) .frame(height: 24) @@ -103,12 +109,12 @@ public struct AuthTextField: View { TextField( label, text: $text, - prompt: Text(prompt).font(typography.resolvedFont(for: .body)) + prompt: promptText ) .frame(height: 24) } } - .authFont(.body) + .authCustomFont(.body) } .frame(maxWidth: .infinity) .keyboardType(keyboardType) diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift index c908927d64..049588b6c9 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift @@ -87,7 +87,7 @@ public struct CountrySelector: View { selectedCountry = country } label: { Text("\(country.flag) \(country.name) (\(country.dialCode))") - .authFont(.body) + .authCustomFont(.body) } .accessibilityIdentifier("country-option-\(country.code)") } diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift index adbf3dd709..629f6147b8 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift @@ -90,6 +90,29 @@ public extension View { } } +extension View { + /// Like `.authFont(_:weight:)`, but only when ``AuthTypography/fontName`` is set. Otherwise + /// the inherited font is left alone (only `weight` is applied), for text that had no explicit + /// font before theming, so an app-level `.font(...)` still reaches it. + package func authCustomFont(_ style: Font.TextStyle, weight: Font.Weight? = nil) -> some View { + modifier(AuthCustomFontModifier(style: style, weight: weight)) + } +} + +struct AuthCustomFontModifier: ViewModifier { + @Environment(\.authTypography) private var typography + let style: Font.TextStyle + var weight: Font.Weight? + + func body(content: Content) -> some View { + if typography.fontName != nil { + content.font(typography.resolvedFont(for: style, weight: weight)) + } else { + content.fontWeight(weight) + } + } +} + private extension Font.TextStyle { var uiKit: UIFont.TextStyle { switch self { From df9329957e1092724b7b6d0e990f33a9ad05d232 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:50:30 +0100 Subject: [PATCH 10/25] feat(auth): add pickerContent and pickerDestination slots to AuthPickerView --- .../Sources/Views/AuthPickerContent.swift | 126 ++++++++++++ .../Sources/Views/AuthPickerView.swift | 184 +++++------------- .../Views/DefaultAuthPickerContent.swift | 92 +++++++++ .../Views/DefaultAuthPickerDestination.swift | 63 ++++++ 4 files changed, 331 insertions(+), 134 deletions(-) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift new file mode 100644 index 0000000000..31635788a4 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift @@ -0,0 +1,126 @@ +// 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 + +/// An ``AuthPickerView`` with custom sheet content, returned by +/// ``AuthPickerView/pickerContent(_:)`` and ``AuthPickerView/pickerDestination(_:)``. +/// +/// It owns the authentication sheet, its `NavigationStack`, error alerts, and MFA and +/// account-conflict handling; only the first screen and the pushed screens come from the slots. +/// You don't create it directly. +@MainActor +public struct AuthPickerContent< + Content: View, + PickerContent: View, + DestinationContent: View +>: View { + init(content: @escaping () -> Content, + pickerContent: @escaping () -> PickerContent, + destination: @escaping (AuthView) -> DestinationContent) { + self.content = content + self.pickerContent = pickerContent + self.destination = destination + } + + @Environment(AuthService.self) private var authService + private let content: () -> Content + private let pickerContent: () -> PickerContent + private let destination: (AuthView) -> DestinationContent + + // View-layer error state + @State private var error: AlertError? + + public var body: some View { + @Bindable var authService = authService + content() + .sheet(isPresented: $authService.isPresented) { + @Bindable var navigator = authService.navigator + NavigationStack(path: $navigator.routes) { + pickerContent() + .navigationTitle(authService.authenticationState == .unauthenticated ? authService + .string.authPickerTitle : "") + .navigationBarTitleDisplayMode(.large) + .toolbar { + toolbar + } + .navigationDestination(for: AuthView.self) { view in + destination(view) + } + } + .environment(\.reportError, reportError) + .errorAlert( + error: $error, + okButtonLabel: authService.string.okButtonLabel + ) + .sheet(item: $authService.legacySignInRecovery) { _ in + LegacySignInRecoveryView() + .environment(authService) + } + .interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled) + // Apply account conflict handling at NavigationStack level + .accountConflictHandler() + // Apply MFA handling at NavigationStack level + .mfaHandler() + .environment(authService) + } + } + + /// Replaces the first screen of the authentication sheet. + public func pickerContent( + @ViewBuilder _ pickerContent: @escaping () -> NewPickerContent + ) -> AuthPickerContent { + AuthPickerContent( + content: content, + pickerContent: pickerContent, + destination: destination + ) + } + + /// Replaces the screens pushed inside the authentication sheet. + public func pickerDestination( + @ViewBuilder _ destination: @escaping (AuthView) -> NewDestinationContent + ) -> AuthPickerContent { + AuthPickerContent( + content: content, + pickerContent: pickerContent, + destination: destination + ) + } + + /// Closure for reporting errors from child views + private func reportError(_ error: Error) { + Task { @MainActor in + self.error = AlertError( + message: authService.string.localizedErrorMessage(for: error), + underlyingError: error + ) + } + } + + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + if !authService.configuration.shouldHideCancelButton { + Button { + authService.isPresented = false + } label: { + Image(systemName: "xmark") + .foregroundStyle(Color(UIColor.label)) + } + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 28fbe389d8..16a998916a 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -17,152 +17,68 @@ import FirebaseAuthUIComponents import FirebaseCore import SwiftUI +/// Wraps your app content and presents the authentication sheet whenever +/// `AuthService.isPresented` is `true`. +/// +/// Use ``pickerContent(_:)`` and ``pickerDestination(_:)`` to replace the sheet's first screen or +/// any pushed screen while the library keeps driving navigation, MFA and account conflicts. @MainActor -public struct AuthPickerView { +public struct AuthPickerView: View { public init(@ViewBuilder content: @escaping () -> Content = { EmptyView() }) { self.content = content } - @Environment(AuthService.self) private var authService private let content: () -> Content - // View-layer error state - @State private var error: AlertError? -} - -extension AuthPickerView: View { public var body: some View { - @Bindable var authService = authService - content() - .sheet(isPresented: $authService.isPresented) { - @Bindable var navigator = authService.navigator - NavigationStack(path: $navigator.routes) { - authPickerViewInternal - .navigationTitle(authService.authenticationState == .unauthenticated ? authService - .string.authPickerTitle : "") - .navigationBarTitleDisplayMode(.large) - .toolbar { - toolbar - } - .navigationDestination(for: AuthView.self) { view in - switch view { - case AuthView.passwordRecovery: - PasswordRecoveryView() - case AuthView.emailLink: - EmailLinkView() - case AuthView.updatePassword: - UpdatePasswordView() - case AuthView.mfaEnrollment: - MFAEnrolmentView() - case AuthView.mfaManagement: - MFAManagementView() - case let .mfaResolution(mfaRequired): - MFAResolutionView(mfaRequired: mfaRequired) - case AuthView.enterPhoneNumber: - EnterPhoneNumberView() - case let .enterVerificationCode(verificationID, fullPhoneNumber): - EnterVerificationCodeView( - verificationID: verificationID, - fullPhoneNumber: fullPhoneNumber - ) - } - } - } - .environment(\.reportError, reportError) - .errorAlert( - error: $error, - okButtonLabel: authService.string.okButtonLabel - ) - .sheet(item: $authService.legacySignInRecovery) { _ in - LegacySignInRecoveryView() - .environment(authService) - } - .interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled) - // Apply account conflict handling at NavigationStack level - .accountConflictHandler() - // Apply MFA handling at NavigationStack level - .mfaHandler() - .environment(authService) - } - } - - /// Closure for reporting errors from child views - private func reportError(_ error: Error) { - Task { @MainActor in - self.error = AlertError( - message: authService.string.localizedErrorMessage(for: error), - underlyingError: error - ) - } - } - - @ToolbarContentBuilder - var toolbar: some ToolbarContent { - ToolbarItem(placement: .topBarTrailing) { - if !authService.configuration.shouldHideCancelButton { - Button { - authService.isPresented = false - } label: { - Image(systemName: "xmark") - .foregroundStyle(Color(UIColor.label)) - } - } - } - } - - @ViewBuilder - var authPickerViewInternal: some View { - @Bindable var authService = authService - VStack { - if authService.authenticationState == .authenticated { - SignedInView() - } else { - authMethodPicker - .safeAreaPadding() - } - } - .overlay { - if authService.authenticationState == .authenticating { - VStack(spacing: 24) { - ProgressView() - .scaleEffect(1.25) - .tint(.white) - Text("Authenticating...") - .authCustomFont(.body) - .foregroundStyle(.white) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.black.opacity(0.7)) - } - } + AuthPickerContent( + content: content, + pickerContent: { DefaultAuthPickerContent() }, + destination: { DefaultAuthPickerDestination(screen: $0) } + ) } - @ViewBuilder - var authMethodPicker: some View { - GeometryReader { proxy in - ScrollView { - VStack(spacing: 24) { - Image(authService.configuration.logo ?? Assets.firebaseAuthLogo) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 100, height: 100) - if authService.emailPasswordSignInEnabled { - EmailAuthView() - } - Divider() - otherSignInOptions(proxy) - PrivacyTOCsView(displayMode: .full) - } - } - } + /// Replaces the first screen of the authentication sheet. + /// + /// ```swift + /// AuthPickerView { authenticatedApp } + /// .pickerContent { + /// DefaultAuthPickerContent() + /// .background(theme.colors.background) + /// } + /// ``` + public func pickerContent( + @ViewBuilder _ pickerContent: @escaping () -> NewPickerContent + ) -> AuthPickerContent { + AuthPickerContent( + content: content, + pickerContent: pickerContent, + destination: { DefaultAuthPickerDestination(screen: $0) } + ) } - @ViewBuilder - func otherSignInOptions(_ proxy: GeometryProxy) -> some View { - VStack { - authService.renderButtons() - } - .padding(.horizontal, proxy.size.width * 0.14) + /// Replaces the screens pushed inside the authentication sheet. Return + /// ``DefaultAuthPickerDestination`` for routes you don't customize. + /// + /// ```swift + /// AuthPickerView { authenticatedApp } + /// .pickerDestination { screen in + /// DefaultAuthPickerDestination(screen: screen) + /// .background(theme.colors.background) + /// } + /// ``` + public func pickerDestination( + @ViewBuilder _ destination: @escaping (AuthView) -> NewDestinationContent + ) -> AuthPickerContent< + Content, + DefaultAuthPickerContent, + NewDestinationContent + > { + AuthPickerContent( + content: content, + pickerContent: { DefaultAuthPickerContent() }, + destination: destination + ) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift new file mode 100644 index 0000000000..30ea1fc3c9 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -0,0 +1,92 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The default first screen of the authentication sheet: the logo, email sign-in, the provider +/// buttons and the terms, or ``SignedInView`` once the user is signed in. +/// +/// Return it from ``AuthPickerView/pickerContent(_:)`` to keep the stock screen while adding +/// modifiers such as a background. +@MainActor +public struct DefaultAuthPickerContent: View { + public init() where AuthMethodPicker == DefaultAuthMethodPicker { + authMethodPicker = { DefaultAuthMethodPicker() } + } + + @Environment(AuthService.self) private var authService + private let authMethodPicker: () -> AuthMethodPicker + + public var body: some View { + VStack { + if authService.authenticationState == .authenticated { + SignedInView() + } else { + signInContent + .safeAreaPadding() + } + } + .overlay { + if authService.authenticationState == .authenticating { + VStack(spacing: 24) { + ProgressView() + .scaleEffect(1.25) + .tint(.white) + Text("Authenticating...") + .authFont(.body) + .foregroundStyle(.white) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.black.opacity(0.7)) + } + } + } + + @ViewBuilder + private var signInContent: some View { + GeometryReader { proxy in + ScrollView { + VStack(spacing: 24) { + Image(authService.configuration.logo ?? Assets.firebaseAuthLogo) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 100, height: 100) + if authService.emailPasswordSignInEnabled { + EmailAuthView() + } + Divider() + authMethodPicker() + .padding(.horizontal, proxy.size.width * 0.14) + PrivacyTOCsView(displayMode: .full) + } + } + } + } +} + +/// The default provider list on ``DefaultAuthPickerContent``: every registered provider's +/// button, stacked vertically. +@MainActor +public struct DefaultAuthMethodPicker: View { + public init() {} + + @Environment(AuthService.self) private var authService + + public var body: some View { + VStack { + authService.renderButtons() + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift new file mode 100644 index 0000000000..9cfc4ac97c --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift @@ -0,0 +1,63 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SwiftUI + +/// The default screen for each route pushed inside the authentication sheet. +/// +/// Return it from ``AuthPickerView/pickerDestination(_:)`` for the routes you don't customize: +/// +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case .enterPhoneNumber: +/// MyPhoneEntry() +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct DefaultAuthPickerDestination: View { + public init(screen: AuthView) { + self.screen = screen + } + + private let screen: AuthView + + public var body: some View { + switch screen { + case AuthView.passwordRecovery: + PasswordRecoveryView() + case AuthView.emailLink: + EmailLinkView() + case AuthView.updatePassword: + UpdatePasswordView() + case AuthView.mfaEnrollment: + MFAEnrolmentView() + case AuthView.mfaManagement: + MFAManagementView() + case let .mfaResolution(mfaRequired): + MFAResolutionView(mfaRequired: mfaRequired) + case AuthView.enterPhoneNumber: + EnterPhoneNumberView() + case let .enterVerificationCode(verificationID, fullPhoneNumber): + EnterVerificationCodeView( + verificationID: verificationID, + fullPhoneNumber: fullPhoneNumber + ) + } + } +} From 1f9a66e98228382427c8c135d92bd7f72213b6d4 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:18:17 +0100 Subject: [PATCH 11/25] fix(auth): keep signed-in and progress UI outside the pickerContent slot --- .../Sources/Views/AuthPickerContent.swift | 36 +++++++++++++++++-- .../Sources/Views/AuthPickerView.swift | 18 +++++++--- .../Views/DefaultAuthPickerContent.swift | 31 ++-------------- .../Views/DefaultAuthPickerDestination.swift | 3 +- 4 files changed, 51 insertions(+), 37 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift index 31635788a4..ae929011cb 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift @@ -13,6 +13,7 @@ // limitations under the License. import FirebaseAuth +import FirebaseAuthUIComponents import SwiftUI /// An ``AuthPickerView`` with custom sheet content, returned by @@ -49,7 +50,7 @@ public struct AuthPickerContent< .sheet(isPresented: $authService.isPresented) { @Bindable var navigator = authService.navigator NavigationStack(path: $navigator.routes) { - pickerContent() + root .navigationTitle(authService.authenticationState == .unauthenticated ? authService .string.authPickerTitle : "") .navigationBarTitleDisplayMode(.large) @@ -78,7 +79,35 @@ public struct AuthPickerContent< } } - /// Replaces the first screen of the authentication sheet. + /// The sheet's first screen. The slot only replaces the signed-out screen, so signed-in users + /// always reach ``SignedInView`` and the progress overlay always shows while signing in. + @ViewBuilder + private var root: some View { + VStack { + if authService.authenticationState == .authenticated { + SignedInView() + } else { + pickerContent() + } + } + .overlay { + if authService.authenticationState == .authenticating { + VStack(spacing: 24) { + ProgressView() + .scaleEffect(1.25) + .tint(.white) + Text("Authenticating...") + .authFont(.body) + .foregroundStyle(.white) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.black.opacity(0.7)) + } + } + } + + /// Replaces the signed-out first screen of the authentication sheet. See + /// ``AuthPickerView/pickerContent(_:)``. public func pickerContent( @ViewBuilder _ pickerContent: @escaping () -> NewPickerContent ) -> AuthPickerContent { @@ -89,7 +118,8 @@ public struct AuthPickerContent< ) } - /// Replaces the screens pushed inside the authentication sheet. + /// Replaces the screens pushed inside the authentication sheet. See + /// ``AuthPickerView/pickerDestination(_:)``. public func pickerDestination( @ViewBuilder _ destination: @escaping (AuthView) -> NewDestinationContent ) -> AuthPickerContent { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 16a998916a..e836347c5b 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -38,7 +38,8 @@ public struct AuthPickerView: View { ) } - /// Replaces the first screen of the authentication sheet. + /// Replaces the signed-out first screen of the authentication sheet. The library still shows + /// ``SignedInView`` to signed-in users and its progress overlay while signing in. /// /// ```swift /// AuthPickerView { authenticatedApp } @@ -57,14 +58,21 @@ public struct AuthPickerView: View { ) } - /// Replaces the screens pushed inside the authentication sheet. Return - /// ``DefaultAuthPickerDestination`` for routes you don't customize. + /// Replaces the screens pushed inside the authentication sheet. + /// + /// Always end a `switch` over the screen with a `default` branch that returns + /// ``DefaultAuthPickerDestination``. New ``AuthView`` routes may be added in future releases, + /// and the `default` branch keeps them working without a code change. /// /// ```swift /// AuthPickerView { authenticatedApp } /// .pickerDestination { screen in - /// DefaultAuthPickerDestination(screen: screen) - /// .background(theme.colors.background) + /// switch screen { + /// case .enterPhoneNumber: + /// MyPhoneEntry() + /// default: + /// DefaultAuthPickerDestination(screen: screen) + /// } /// } /// ``` public func pickerDestination( diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift index 30ea1fc3c9..c6e95be541 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -15,8 +15,8 @@ import FirebaseAuthUIComponents import SwiftUI -/// The default first screen of the authentication sheet: the logo, email sign-in, the provider -/// buttons and the terms, or ``SignedInView`` once the user is signed in. +/// The default signed-out first screen of the authentication sheet: the logo, email sign-in, +/// the provider buttons and the terms. /// /// Return it from ``AuthPickerView/pickerContent(_:)`` to keep the stock screen while adding /// modifiers such as a background. @@ -30,32 +30,6 @@ public struct DefaultAuthPickerContent: View { private let authMethodPicker: () -> AuthMethodPicker public var body: some View { - VStack { - if authService.authenticationState == .authenticated { - SignedInView() - } else { - signInContent - .safeAreaPadding() - } - } - .overlay { - if authService.authenticationState == .authenticating { - VStack(spacing: 24) { - ProgressView() - .scaleEffect(1.25) - .tint(.white) - Text("Authenticating...") - .authFont(.body) - .foregroundStyle(.white) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.black.opacity(0.7)) - } - } - } - - @ViewBuilder - private var signInContent: some View { GeometryReader { proxy in ScrollView { VStack(spacing: 24) { @@ -73,6 +47,7 @@ public struct DefaultAuthPickerContent: View { } } } + .safeAreaPadding() } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift index 9cfc4ac97c..2d5762c3ca 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift @@ -16,7 +16,8 @@ import SwiftUI /// The default screen for each route pushed inside the authentication sheet. /// -/// Return it from ``AuthPickerView/pickerDestination(_:)`` for the routes you don't customize: +/// Return it from ``AuthPickerView/pickerDestination(_:)`` for the routes you don't customize. +/// Keep a `default` branch: new ``AuthView`` routes may be added in future releases. /// /// ```swift /// AuthPickerView { authenticatedApp } From a6caa62c0873cb20d899451b61c91d8057e244a3 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:06:29 +0100 Subject: [PATCH 12/25] docs(auth): add usage examples to AuthPickerView and authFont --- .../Sources/Views/AuthPickerView.swift | 8 ++++++++ .../Sources/Theme/AuthTypography.swift | 10 ++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index e836347c5b..17b142d3cd 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -22,6 +22,14 @@ import SwiftUI /// /// Use ``pickerContent(_:)`` and ``pickerDestination(_:)`` to replace the sheet's first screen or /// any pushed screen while the library keeps driving navigation, MFA and account conflicts. +/// +/// Example: +/// ```swift +/// AuthPickerView { +/// HomeView() +/// } +/// .environment(authService) +/// ``` @MainActor public struct AuthPickerView: View { public init(@ViewBuilder content: @escaping () -> Content = { EmptyView() }) { diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift index 629f6147b8..113ae587e7 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/AuthTypography.swift @@ -83,8 +83,14 @@ struct AuthFontModifier: ViewModifier { } public extension View { - /// Applies a semantic text style, resolved against the environment's ``AuthTypography`` — - /// use in place of a bare `.font(.headline)`/`.font(.caption)`/etc. call. + /// Applies a semantic text style resolved against the environment's ``AuthTypography``, so + /// your own views inside the auth flow use the same font as the stock screens. + /// + /// Example: + /// ```swift + /// Text("Welcome back") + /// .authFont(.title2, weight: .semibold) + /// ``` func authFont(_ style: Font.TextStyle, weight: Font.Weight? = nil) -> some View { modifier(AuthFontModifier(style: style, weight: weight)) } From f0fa077fd615979007128707b748a497ad6a1091 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:43:54 +0100 Subject: [PATCH 13/25] fix(auth): let the authenticating overlay inherit the app font when no theme is set --- .../FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift index ae929011cb..526861fb52 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift @@ -97,7 +97,7 @@ public struct AuthPickerContent< .scaleEffect(1.25) .tint(.white) Text("Authenticating...") - .authFont(.body) + .authCustomFont(.body) .foregroundStyle(.white) } .frame(maxWidth: .infinity, maxHeight: .infinity) From df2c4fc5f7b4dfbe94ed469149ba49df7df6c241 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:31:42 +0100 Subject: [PATCH 14/25] feat(auth): support custom method picker layouts in DefaultAuthPickerContent Add DefaultAuthPickerContent(authMethodPicker:), which hands the layout an AuthMethodPickerState with the registered providers, a select action that routes MFA, account conflicts and errors like the default buttons, and the email-link state and action. Add AuthService.registeredProviders and triggerSignIn(for:), plus the opt-in AuthProviderAction protocol for providers whose sign-in yields no credential; PhoneAuthProviderAuthUI adopts it. Providers that support neither path report an error instead of doing nothing. Move the 14% horizontal inset into DefaultAuthMethodPicker so custom layouts are not padded. --- .../Sources/Services/AuthService.swift | 58 ++++- .../Views/DefaultAuthPickerContent.swift | 140 +++++++++-- .../Services/PhoneAuthProviderAuthUI.swift | 6 + .../TriggerSignInTests.swift | 225 ++++++++++++++++++ 4 files changed, 406 insertions(+), 23 deletions(-) create mode 100644 e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TriggerSignInTests.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 2145b6733e..2bd86e1605 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -33,6 +33,24 @@ public protocol AuthProviderUI { var provider: AuthProviderSwift { get } } +/// Lets ``AuthService/triggerSignIn(for:)`` start sign-in for a provider whose flow doesn't +/// produce a credential, such as phone or a custom flow that presents its own screens. +/// +/// Adopt it on a custom provider so a custom method picker layout can start its sign-in through +/// ``AuthMethodPickerState/select``. Otherwise, start sign-in from the provider's own button. +/// +/// Example: +/// ```swift +/// extension MyProviderAuthUI: AuthProviderAction { +/// func triggerAction() async throws { +/// try await startMySignInFlow() +/// } +/// } +/// ``` +public protocol AuthProviderAction: AuthProviderUI { + @MainActor func triggerAction() async throws +} + public enum AuthenticationState { case unauthenticated case authenticating @@ -184,6 +202,40 @@ public final class AuthService { providers.append(providerWithButton) } + /// The providers registered with ``registerProvider(providerWithButton:)``, in registration + /// order. Email and email-link sign-in aren't included. + public var registeredProviders: [AuthProviderUI] { + providers + } + + /// Starts sign-in with `provider` without rendering its button. + /// + /// ``AuthProviderAction`` providers run ``AuthProviderAction/triggerAction()``, in which case + /// this returns `nil`. Other credential-based providers sign in through ``signIn(_:)``. + /// Any other provider throws ``AuthServiceError/providerAuthenticationFailed(_:)``. + public func triggerSignIn(for provider: AuthProviderUI) async throws -> SignInOutcome? { + if let actionProvider = provider as? AuthProviderAction { + try await actionProvider.triggerAction() + return nil + } + if let credentialProvider = provider.provider as? CredentialAuthProviderSwift { + return try await signIn(credentialProvider) + } + throw AuthServiceError.providerAuthenticationFailed( + "\(provider.displayName) doesn't support starting sign-in programmatically. " + + "Adopt AuthProviderAction on the provider, or start sign-in from its own button." + ) + } + + /// Runs the email-link sign-in action, the same as the stock email-link button. + func selectEmailLink() { + if let callback = emailLinkSignInCallback { + callback() + } else { + navigator.push(.emailLink) + } + } + public func renderButtons(spacing: CGFloat = 16) -> AnyView { AnyView( VStack(spacing: spacing) { @@ -193,11 +245,7 @@ public final class AuthService { style: .email, accessibilityId: "sign-in-with-email-link-button" ) { - if let callback = self.emailLinkSignInCallback { - callback() - } else { - self.navigator.push(.emailLink) - } + self.selectEmailLink() } } ForEach(providers, id: \.id) { provider in diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift index c6e95be541..372ee3ade2 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -19,40 +19,142 @@ import SwiftUI /// the provider buttons and the terms. /// /// Return it from ``AuthPickerView/pickerContent(_:)`` to keep the stock screen while adding -/// modifiers such as a background. +/// modifiers such as a background, or use ``init(authMethodPicker:)`` to lay out the provider +/// buttons yourself. @MainActor public struct DefaultAuthPickerContent: View { public init() where AuthMethodPicker == DefaultAuthMethodPicker { - authMethodPicker = { DefaultAuthMethodPicker() } + authMethodPicker = { _ in DefaultAuthMethodPicker() } + } + + /// Creates the default screen with your own layout for the provider buttons. + /// + /// The closure receives an ``AuthMethodPickerState``. Call its `select` to sign in with a + /// provider, so MFA, account conflicts and errors are handled the same as the default buttons. + /// Show an email-link button when `isEmailLinkEnabled` is `true`. + /// + /// Example: + /// ```swift + /// DefaultAuthPickerContent { state in + /// ForEach(state.providers, id: \.id) { provider in + /// Button(provider.displayName) { state.select(provider) } + /// } + /// if state.isEmailLinkEnabled { + /// Button("Email link", action: state.selectEmailLink) + /// } + /// } + /// ``` + public init(@ViewBuilder authMethodPicker: @escaping (AuthMethodPickerState) + -> AuthMethodPicker) { + self.authMethodPicker = authMethodPicker } @Environment(AuthService.self) private var authService - private let authMethodPicker: () -> AuthMethodPicker + @Environment(\.mfaHandler) private var mfaHandler + @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.reportError) private var reportError + private let authMethodPicker: (AuthMethodPickerState) -> AuthMethodPicker public var body: some View { - GeometryReader { proxy in - ScrollView { - VStack(spacing: 24) { - Image(authService.configuration.logo ?? Assets.firebaseAuthLogo) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 100, height: 100) - if authService.emailPasswordSignInEnabled { - EmailAuthView() - } - Divider() - authMethodPicker() - .padding(.horizontal, proxy.size.width * 0.14) - PrivacyTOCsView(displayMode: .full) + ScrollView { + VStack(spacing: 24) { + Image(authService.configuration.logo ?? Assets.firebaseAuthLogo) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 100, height: 100) + if authService.emailPasswordSignInEnabled { + EmailAuthView() + } + Divider() + VStack { + authMethodPicker(pickerState) } + PrivacyTOCsView(displayMode: .full) } } .safeAreaPadding() } + + private var pickerState: AuthMethodPickerState { + AuthMethodPickerState( + providers: authService.registeredProviders, + select: selectProvider, + isEmailLinkEnabled: authService.emailLinkSignInEnabled, + selectEmailLink: authService.selectEmailLink + ) + } + + private func selectProvider(_ provider: AuthProviderUI) { + Task { + let result: Result + do { + result = try await .success(authService.triggerSignIn(for: provider)) + } catch { + result = .failure(error) + } + AuthMethodPickerState.route( + result, + mfaHandler: mfaHandler, + accountConflictHandler: accountConflictHandler, + reportError: reportError + ) + } + } +} + +/// The providers and sign-in actions passed to a custom layout in +/// ``DefaultAuthPickerContent/init(authMethodPicker:)``. +public struct AuthMethodPickerState { + /// The registered providers, in registration order. Email-link sign-in isn't included; see + /// ``isEmailLinkEnabled``. + public let providers: [AuthProviderUI] + + /// Signs in with a provider from ``providers``. A provider that is neither credential-based + /// nor an ``AuthProviderAction`` reports an error instead. + public let select: @MainActor (AuthProviderUI) -> Void + + /// Whether email-link sign-in is enabled on the ``AuthService``. + public let isEmailLinkEnabled: Bool + + /// Starts email-link sign-in, the same as the stock email-link button. + public let selectEmailLink: @MainActor () -> Void + + /// Creates a state, for example to preview a custom layout with stub actions. + public init(providers: [AuthProviderUI], + select: @escaping @MainActor (AuthProviderUI) -> Void, + isEmailLinkEnabled: Bool, + selectEmailLink: @escaping @MainActor () -> Void) { + self.providers = providers + self.select = select + self.isEmailLinkEnabled = isEmailLinkEnabled + self.selectEmailLink = selectEmailLink + } + + /// Hands a provider sign-in result to the same handlers the default provider buttons use: + /// MFA and account conflicts go to their handlers, and other errors are reported. + @MainActor + static func route(_ result: Result, + mfaHandler: ((MFARequired) -> Void)?, + accountConflictHandler: ((AccountConflictContext) -> Void)?, + reportError: ((Error) -> Void)?) { + switch result { + case let .success(.mfaRequired(mfaInfo)): + mfaHandler?(mfaInfo) + case .success: + break + case let .failure(AuthServiceError.accountConflict(ctx)) where accountConflictHandler != nil: + accountConflictHandler?(ctx) + case let .failure(error): + reportError?(error) + } + } } /// The default provider list on ``DefaultAuthPickerContent``: every registered provider's -/// button, stacked vertically. +/// button, stacked vertically and inset from the screen edges. +/// +/// Its width is 72% of the nearest container, such as the enclosing `ScrollView`, not of its +/// direct parent. @MainActor public struct DefaultAuthMethodPicker: View { public init() {} @@ -63,5 +165,7 @@ public struct DefaultAuthMethodPicker: View { VStack { authService.renderButtons() } + // 72% of the container width, matching a 14% inset on each side. + .containerRelativeFrame(.horizontal) { length, _ in length * 0.72 } } } diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift index 5d835b959d..a816970098 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift @@ -41,3 +41,9 @@ public class PhoneAuthProviderAuthUI: AuthProviderUI { return AnyView(PhoneAuthButtonView(onTap: mainActorClosure)) } } + +extension PhoneAuthProviderAuthUI: AuthProviderAction { + @MainActor public func triggerAction() async throws { + onTap() + } +} diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TriggerSignInTests.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TriggerSignInTests.swift new file mode 100644 index 0000000000..00ae7d88c4 --- /dev/null +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TriggerSignInTests.swift @@ -0,0 +1,225 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +@testable import FirebaseAuthSwiftUI +import FirebaseCore +import FirebasePhoneAuthSwiftUI +import SwiftUI +import Testing + +private struct TestCredentialError: Error {} + +private final class FailingCredentialProvider: CredentialAuthProviderSwift { + func createAuthCredential() async throws -> AuthCredential { + throw TestCredentialError() + } +} + +private final class PlainProvider: AuthProviderSwift {} + +private class TestProviderUI: AuthProviderUI { + let id: String + let displayName: String + let provider: AuthProviderSwift + + init(id: String, provider: AuthProviderSwift) { + self.id = id + displayName = id + self.provider = provider + } + + @MainActor func authButton() -> AnyView { + AnyView(EmptyView()) + } +} + +private class TestActionProviderUI: TestProviderUI, AuthProviderAction { + var actionCount = 0 + + @MainActor func triggerAction() async throws { + actionCount += 1 + } +} + +@MainActor +struct TriggerSignInTests { + private func makeService() -> AuthService { + configureFirebaseIfNeeded() + return AuthService() + } + + @Test + func registeredProvidersKeepRegistrationOrder() { + let service = makeService() + service.registerProvider(providerWithButton: TestProviderUI(id: "a", provider: PlainProvider())) + service.registerProvider(providerWithButton: TestProviderUI(id: "b", provider: PlainProvider())) + + #expect(service.registeredProviders.map(\.id) == ["a", "b"]) + } + + @Test + func credentialProviderSignsInThroughItsCredential() async { + let service = makeService() + let provider = TestProviderUI(id: "credential", provider: FailingCredentialProvider()) + + await #expect(throws: TestCredentialError.self) { + try await service.triggerSignIn(for: provider) + } + } + + @Test + func actionTakesPrecedenceOverCredential() async throws { + let service = makeService() + let provider = TestActionProviderUI(id: "both", provider: FailingCredentialProvider()) + + let outcome = try await service.triggerSignIn(for: provider) + + #expect(outcome == nil) + #expect(provider.actionCount == 1) + } + + @Test + func actionProviderRunsItsAction() async throws { + let service = makeService() + let provider = TestActionProviderUI(id: "action", provider: PlainProvider()) + + let outcome = try await service.triggerSignIn(for: provider) + + #expect(outcome == nil) + #expect(provider.actionCount == 1) + } + + @Test + func unsupportedProviderThrows() async { + let service = makeService() + let provider = TestProviderUI(id: "custom", provider: PlainProvider()) + + await #expect { + try await service.triggerSignIn(for: provider) + } throws: { error in + guard case AuthServiceError.providerAuthenticationFailed = error else { return false } + return true + } + } + + @Test + func phoneProviderRunsItsTapAction() async throws { + let service = makeService() + var tapCount = 0 + let provider = PhoneAuthProviderAuthUI { tapCount += 1 } + + let outcome = try await service.triggerSignIn(for: provider) + + #expect(outcome == nil) + #expect(tapCount == 1) + } + + @Test + func selectEmailLinkRunsCustomCallback() { + let service = makeService() + var callbackCount = 0 + _ = service.withEmailLinkSignIn { callbackCount += 1 } + + service.selectEmailLink() + + #expect(callbackCount == 1) + #expect(service.authView == nil) + } + + @Test + func selectEmailLinkPushesEmailLinkScreenByDefault() { + let service = makeService() + _ = service.withEmailLinkSignIn() + + service.selectEmailLink() + + #expect(service.authView == .emailLink) + } +} + +@MainActor +struct AuthMethodPickerStateRouteTests { + private final class Calls { + var mfa: [MFARequired] = [] + var conflicts: [AccountConflictContext] = [] + var errors: [Error] = [] + } + + private func route(_ result: Result, + withConflictHandler: Bool = true) -> Calls { + let calls = Calls() + AuthMethodPickerState.route( + result, + mfaHandler: { calls.mfa.append($0) }, + accountConflictHandler: withConflictHandler ? { calls.conflicts.append($0) } : nil, + reportError: { calls.errors.append($0) } + ) + return calls + } + + private func conflictError() -> AuthServiceError { + .accountConflict(AccountConflictContext( + conflictType: .accountExistsWithDifferentCredential, + credential: EmailAuthProvider.credential(withEmail: "a@b.com", password: "123456"), + underlyingError: TestCredentialError(), + message: "conflict", + email: "a@b.com" + )) + } + + @Test + func mfaRequiredGoesToMFAHandler() { + let calls = route(.success(.mfaRequired(MFARequired(hints: [])))) + + #expect(calls.mfa.count == 1) + #expect(calls.conflicts.isEmpty) + #expect(calls.errors.isEmpty) + } + + @Test + func successAndNoOutcomeCallNothing() { + for result: Result in [.success(nil), .success(.signedIn(nil))] { + let calls = route(result) + + #expect(calls.mfa.isEmpty) + #expect(calls.conflicts.isEmpty) + #expect(calls.errors.isEmpty) + } + } + + @Test + func accountConflictGoesToConflictHandler() { + let calls = route(.failure(conflictError())) + + #expect(calls.conflicts.count == 1) + #expect(calls.errors.isEmpty) + } + + @Test + func accountConflictWithoutHandlerIsReported() { + let calls = route(.failure(conflictError()), withConflictHandler: false) + + #expect(calls.errors.count == 1) + } + + @Test + func otherErrorsAreReported() { + let calls = route(.failure(TestCredentialError())) + + #expect(calls.errors.first is TestCredentialError) + #expect(calls.mfa.isEmpty) + #expect(calls.conflicts.isEmpty) + } +} From c72d5993131040c6eeb1009a57305388c58d17d9 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:52:56 +0100 Subject: [PATCH 15/25] refactor(auth): simplify provider selection and default method picker --- .../Sources/Views/DefaultAuthPickerContent.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift index 372ee3ade2..7cf211d99b 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -88,7 +88,8 @@ public struct DefaultAuthPickerContent: View { Task { let result: Result do { - result = try await .success(authService.triggerSignIn(for: provider)) + let outcome = try await authService.triggerSignIn(for: provider) + result = .success(outcome) } catch { result = .failure(error) } @@ -162,10 +163,8 @@ public struct DefaultAuthMethodPicker: View { @Environment(AuthService.self) private var authService public var body: some View { - VStack { - authService.renderButtons() - } - // 72% of the container width, matching a 14% inset on each side. - .containerRelativeFrame(.horizontal) { length, _ in length * 0.72 } + authService.renderButtons() + // 72% of the container width, matching a 14% inset on each side. + .containerRelativeFrame(.horizontal) { length, _ in length * 0.72 } } } From 74397c00dd8cbacde70c4310f24aeee0cdd48d04 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:32:40 +0100 Subject: [PATCH 16/25] feat(auth): pass AuthMethodPickerState to a whole-screen pickerContent --- .../Sources/Views/AuthPickerContent.swift | 20 ++++++++++-- .../Sources/Views/AuthPickerView.swift | 29 +++++++++++++++-- .../Views/DefaultAuthPickerContent.swift | 32 ++++++++++++++----- 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift index 526861fb52..adb2d70b8c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift @@ -29,7 +29,7 @@ public struct AuthPickerContent< DestinationContent: View >: View { init(content: @escaping () -> Content, - pickerContent: @escaping () -> PickerContent, + pickerContent: @escaping (AuthMethodPickerState) -> PickerContent, destination: @escaping (AuthView) -> DestinationContent) { self.content = content self.pickerContent = pickerContent @@ -38,7 +38,7 @@ public struct AuthPickerContent< @Environment(AuthService.self) private var authService private let content: () -> Content - private let pickerContent: () -> PickerContent + private let pickerContent: (AuthMethodPickerState) -> PickerContent private let destination: (AuthView) -> DestinationContent // View-layer error state @@ -87,7 +87,9 @@ public struct AuthPickerContent< if authService.authenticationState == .authenticated { SignedInView() } else { - pickerContent() + AuthMethodPickerStateReader { state in + pickerContent(state) + } } } .overlay { @@ -110,6 +112,18 @@ public struct AuthPickerContent< /// ``AuthPickerView/pickerContent(_:)``. public func pickerContent( @ViewBuilder _ pickerContent: @escaping () -> NewPickerContent + ) -> AuthPickerContent { + AuthPickerContent( + content: content, + pickerContent: { _ in pickerContent() }, + destination: destination + ) + } + + /// Replaces the whole signed-out first screen with your own, receiving an + /// ``AuthMethodPickerState``. See the matching `pickerContent` on ``AuthPickerView``. + public func pickerContent( + @ViewBuilder _ pickerContent: @escaping (AuthMethodPickerState) -> NewPickerContent ) -> AuthPickerContent { AuthPickerContent( content: content, diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 17b142d3cd..36a01e7f22 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -41,7 +41,7 @@ public struct AuthPickerView: View { public var body: some View { AuthPickerContent( content: content, - pickerContent: { DefaultAuthPickerContent() }, + pickerContent: { _ in DefaultAuthPickerContent() }, destination: { DefaultAuthPickerDestination(screen: $0) } ) } @@ -58,6 +58,31 @@ public struct AuthPickerView: View { /// ``` public func pickerContent( @ViewBuilder _ pickerContent: @escaping () -> NewPickerContent + ) -> AuthPickerContent { + AuthPickerContent( + content: content, + pickerContent: { _ in pickerContent() }, + destination: { DefaultAuthPickerDestination(screen: $0) } + ) + } + + /// Replaces the whole signed-out first screen with your own, receiving an + /// ``AuthMethodPickerState`` to sign in with the registered providers. Call its `select` so MFA, + /// account conflicts and errors are handled the same as the default buttons. + /// + /// Your screen replaces the logo, email sign-in and terms too, so show your own email entry + /// (for example ``EmailAuthScreen``) and any legal text you need. To keep the stock screen and + /// only lay out the provider buttons, use ``DefaultAuthPickerContent/init(authMethodPicker:)``. + /// + /// Example: + /// ```swift + /// AuthPickerView { authenticatedApp } + /// .pickerContent { state in + /// MyLandingScreen(state: state) + /// } + /// ``` + public func pickerContent( + @ViewBuilder _ pickerContent: @escaping (AuthMethodPickerState) -> NewPickerContent ) -> AuthPickerContent { AuthPickerContent( content: content, @@ -92,7 +117,7 @@ public struct AuthPickerView: View { > { AuthPickerContent( content: content, - pickerContent: { DefaultAuthPickerContent() }, + pickerContent: { _ in DefaultAuthPickerContent() }, destination: destination ) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift index 7cf211d99b..84ce375888 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -50,9 +50,6 @@ public struct DefaultAuthPickerContent: View { } @Environment(AuthService.self) private var authService - @Environment(\.mfaHandler) private var mfaHandler - @Environment(\.accountConflictHandler) private var accountConflictHandler - @Environment(\.reportError) private var reportError private let authMethodPicker: (AuthMethodPickerState) -> AuthMethodPicker public var body: some View { @@ -67,21 +64,39 @@ public struct DefaultAuthPickerContent: View { } Divider() VStack { - authMethodPicker(pickerState) + AuthMethodPickerStateReader { state in + authMethodPicker(state) + } } PrivacyTOCsView(displayMode: .full) } } .safeAreaPadding() } +} + +/// Builds an ``AuthMethodPickerState`` from the environment and passes it to its content, so +/// provider selection routes MFA, account conflicts and errors through the same handlers as the +/// default buttons. Used by ``DefaultAuthPickerContent`` and ``AuthPickerContent``. +@MainActor +struct AuthMethodPickerStateReader: View { + init(@ViewBuilder content: @escaping (AuthMethodPickerState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.mfaHandler) private var mfaHandler + @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.reportError) private var reportError + private let content: (AuthMethodPickerState) -> Content - private var pickerState: AuthMethodPickerState { - AuthMethodPickerState( + var body: some View { + content(AuthMethodPickerState( providers: authService.registeredProviders, select: selectProvider, isEmailLinkEnabled: authService.emailLinkSignInEnabled, selectEmailLink: authService.selectEmailLink - ) + )) } private func selectProvider(_ provider: AuthProviderUI) { @@ -103,7 +118,8 @@ public struct DefaultAuthPickerContent: View { } } -/// The providers and sign-in actions passed to a custom layout in +/// The providers and sign-in actions passed to a custom first screen in +/// `AuthPickerView.pickerContent { state in ... }`, or to a custom provider layout in /// ``DefaultAuthPickerContent/init(authMethodPicker:)``. public struct AuthMethodPickerState { /// The registered providers, in registration order. Email-link sign-in isn't included; see From 6816cd1c507b2f9b1e2bc20c5fced9bad6149c7b Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:45:04 +0100 Subject: [PATCH 17/25] fix(auth): hide the picker title for a whole-screen pickerContent and fix doc links --- .../Sources/Views/AuthPickerContent.swift | 31 +++++++++++++------ .../Sources/Views/AuthPickerView.swift | 12 ++++--- .../Views/DefaultAuthPickerContent.swift | 2 +- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift index adb2d70b8c..32852e8c47 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift @@ -17,7 +17,7 @@ import FirebaseAuthUIComponents import SwiftUI /// An ``AuthPickerView`` with custom sheet content, returned by -/// ``AuthPickerView/pickerContent(_:)`` and ``AuthPickerView/pickerDestination(_:)``. +/// `AuthPickerView`'s `pickerContent` and ``AuthPickerView/pickerDestination(_:)`` modifiers. /// /// It owns the authentication sheet, its `NavigationStack`, error alerts, and MFA and /// account-conflict handling; only the first screen and the pushed screens come from the slots. @@ -30,16 +30,21 @@ public struct AuthPickerContent< >: View { init(content: @escaping () -> Content, pickerContent: @escaping (AuthMethodPickerState) -> PickerContent, - destination: @escaping (AuthView) -> DestinationContent) { + destination: @escaping (AuthView) -> DestinationContent, + showsPickerTitle: Bool = true) { self.content = content self.pickerContent = pickerContent self.destination = destination + self.showsPickerTitle = showsPickerTitle } @Environment(AuthService.self) private var authService private let content: () -> Content private let pickerContent: (AuthMethodPickerState) -> PickerContent private let destination: (AuthView) -> DestinationContent + /// `false` when the whole first screen is custom, so the library's large title doesn't sit + /// above the app's own heading. The close button stays so the sheet can still be dismissed. + private let showsPickerTitle: Bool // View-layer error state @State private var error: AlertError? @@ -51,14 +56,19 @@ public struct AuthPickerContent< @Bindable var navigator = authService.navigator NavigationStack(path: $navigator.routes) { root - .navigationTitle(authService.authenticationState == .unauthenticated ? authService - .string.authPickerTitle : "") - .navigationBarTitleDisplayMode(.large) + .navigationTitle( + showsPickerTitle && authService.authenticationState == .unauthenticated + ? authService.string.authPickerTitle : "" + ) + .navigationBarTitleDisplayMode(showsPickerTitle ? .large : .inline) .toolbar { toolbar } .navigationDestination(for: AuthView.self) { view in destination(view) + // Pushed screens inherit an `.automatic` title mode from the root. Keep them + // large when the root is inline because a custom first screen hid its title. + .navigationBarTitleDisplayMode(showsPickerTitle ? .automatic : .large) } } .environment(\.reportError, reportError) @@ -109,14 +119,15 @@ public struct AuthPickerContent< } /// Replaces the signed-out first screen of the authentication sheet. See - /// ``AuthPickerView/pickerContent(_:)``. + /// the no-argument `pickerContent` on ``AuthPickerView``. public func pickerContent( @ViewBuilder _ pickerContent: @escaping () -> NewPickerContent ) -> AuthPickerContent { AuthPickerContent( content: content, pickerContent: { _ in pickerContent() }, - destination: destination + destination: destination, + showsPickerTitle: true ) } @@ -128,7 +139,8 @@ public struct AuthPickerContent< AuthPickerContent( content: content, pickerContent: pickerContent, - destination: destination + destination: destination, + showsPickerTitle: false ) } @@ -140,7 +152,8 @@ public struct AuthPickerContent< AuthPickerContent( content: content, pickerContent: pickerContent, - destination: destination + destination: destination, + showsPickerTitle: showsPickerTitle ) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 36a01e7f22..db685ca1fc 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -20,7 +20,7 @@ import SwiftUI /// Wraps your app content and presents the authentication sheet whenever /// `AuthService.isPresented` is `true`. /// -/// Use ``pickerContent(_:)`` and ``pickerDestination(_:)`` to replace the sheet's first screen or +/// Use the `pickerContent` modifiers and ``pickerDestination(_:)`` to replace the sheet's first screen or /// any pushed screen while the library keeps driving navigation, MFA and account conflicts. /// /// Example: @@ -70,9 +70,10 @@ public struct AuthPickerView: View { /// ``AuthMethodPickerState`` to sign in with the registered providers. Call its `select` so MFA, /// account conflicts and errors are handled the same as the default buttons. /// - /// Your screen replaces the logo, email sign-in and terms too, so show your own email entry - /// (for example ``EmailAuthScreen``) and any legal text you need. To keep the stock screen and - /// only lay out the provider buttons, use ``DefaultAuthPickerContent/init(authMethodPicker:)``. + /// Your screen replaces the logo, email sign-in, terms and the large "Sign in" title (the close + /// button stays), so show your own heading, email entry (for example one built around + /// ``EmailAuthView``) and any legal text you need. To keep the stock screen and only lay out + /// the provider buttons, use ``DefaultAuthPickerContent/init(authMethodPicker:)``. /// /// Example: /// ```swift @@ -87,7 +88,8 @@ public struct AuthPickerView: View { AuthPickerContent( content: content, pickerContent: pickerContent, - destination: { DefaultAuthPickerDestination(screen: $0) } + destination: { DefaultAuthPickerDestination(screen: $0) }, + showsPickerTitle: false ) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift index 84ce375888..cb444ca858 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -18,7 +18,7 @@ import SwiftUI /// The default signed-out first screen of the authentication sheet: the logo, email sign-in, /// the provider buttons and the terms. /// -/// Return it from ``AuthPickerView/pickerContent(_:)`` to keep the stock screen while adding +/// Return it from `AuthPickerView`'s no-argument `pickerContent` to keep the stock screen while adding /// modifiers such as a background, or use ``init(authMethodPicker:)`` to lay out the provider /// buttons yourself. @MainActor From 9ed03af898ac082040d48c89ad3d8b7b785c6dba Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:09:17 +0100 Subject: [PATCH 18/25] test(auth): cover AuthMethodPickerState routing to the MFA, conflict and error handlers --- .../AuthMethodPickerStateTests.swift | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/AuthMethodPickerStateTests.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/AuthMethodPickerStateTests.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/AuthMethodPickerStateTests.swift new file mode 100644 index 0000000000..c88876b984 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/AuthMethodPickerStateTests.swift @@ -0,0 +1,100 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +@testable import FirebaseAuthSwiftUI +import Foundation +import Testing + +/// Records which handler `AuthMethodPickerState.route` called. +@MainActor +private final class RouteRecorder { + var mfa: MFARequired? + var conflict: AccountConflictContext? + var error: Error? + + func route(_ result: Result, handlesConflicts: Bool = true) { + AuthMethodPickerState.route( + result, + mfaHandler: { self.mfa = $0 }, + accountConflictHandler: handlesConflicts ? { self.conflict = $0 } : nil, + reportError: { self.error = $0 } + ) + } +} + +private struct TestError: Error {} + +private func conflictContext() -> AccountConflictContext { + AccountConflictContext( + conflictType: .accountExistsWithDifferentCredential, + credential: EmailAuthProvider.credential(withEmail: "test@example.com", password: "123456"), + underlyingError: TestError(), + message: "conflict", + email: "test@example.com" + ) +} + +@Suite("AuthMethodPickerState routing") +@MainActor +struct AuthMethodPickerStateRoutingTests { + @Test("MFA required goes to the MFA handler") + func mfaRequired() { + let recorder = RouteRecorder() + let mfa = MFARequired(hints: [.totp(displayName: nil, uid: "uid")]) + recorder.route(.success(.mfaRequired(mfa))) + #expect(recorder.mfa == mfa) + #expect(recorder.conflict == nil) + #expect(recorder.error == nil) + } + + @Test("A completed sign-in calls no handler") + func signedIn() { + let recorder = RouteRecorder() + recorder.route(.success(.signedIn(nil))) + recorder.route(.success(nil)) + #expect(recorder.mfa == nil) + #expect(recorder.conflict == nil) + #expect(recorder.error == nil) + } + + @Test("An account conflict goes to the conflict handler") + func accountConflict() { + let recorder = RouteRecorder() + let context = conflictContext() + recorder.route(.failure(AuthServiceError.accountConflict(context))) + #expect(recorder.conflict == context) + #expect(recorder.error == nil) + } + + @Test("An account conflict is reported when there is no conflict handler") + func accountConflictWithoutHandler() { + let recorder = RouteRecorder() + recorder.route( + .failure(AuthServiceError.accountConflict(conflictContext())), + handlesConflicts: false + ) + #expect(recorder.conflict == nil) + #expect(recorder.error != nil) + } + + @Test("Other errors are reported") + func otherError() { + let recorder = RouteRecorder() + recorder.route(.failure(TestError())) + #expect(recorder.error is TestError) + #expect(recorder.mfa == nil) + #expect(recorder.conflict == nil) + } +} From 02bb2b077c0023e998efc377fae8b843e5362090 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 19/25] 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 8070ba59fc5706a2d41f5d053bd323607f56bfff 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 20/25] 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 ba7ecda7b74ecb31549e3b44d7f045346aba70a8 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 21/25] 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 4efe16c8cd5dbe43e3192b355f34778a831ce122 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 22/25] 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 67cc632da399aa39926490b95eb7074a977c4ca4 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 23/25] 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 bce0016a41ddeccc33c77bfd449543cb7d3740b6 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 24/25] 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 bdc298d9884a460a5d011a0308961d09ed1b0f2e 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 25/25] 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],