diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index ce0d196d7a..35608ca6fb 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -46,9 +46,11 @@ public struct EmailAuthView { private var isValid: Bool { return if authService.authenticationFlow == .signIn { - !email.isEmpty && !password.isEmpty + FormValidators.email.isValid(input: email) && !password.isEmpty } else { - !email.isEmpty && !password.isEmpty && password == confirmPassword + FormValidators.email.isValid(input: email) && + FormValidators.atLeast6Characters.isValid(input: password) && + FormValidators.confirmPassword(password: password).isValid(input: confirmPassword) } } @@ -108,6 +110,10 @@ extension EmailAuthView: View { prompt: authService.string.emailInputLabel, keyboardType: .emailAddress, contentType: .emailAddress, + validations: [ + FormValidators.email + ], + maintainsValidationMessage: authService.authenticationFlow == .signUp, onSubmit: { _ in self.focus = .password }, @@ -122,7 +128,11 @@ extension EmailAuthView: View { label: authService.string.passwordFieldLabel, prompt: authService.string.passwordInputLabel, contentType: .password, - sensitive: true, + isSecureTextField: true, + validations: authService.authenticationFlow == .signUp ? [ + FormValidators.atLeast6Characters + ] : [], + maintainsValidationMessage: authService.authenticationFlow == .signUp, onSubmit: { _ in Task { try await signInWithEmailPassword() } }, @@ -149,7 +159,11 @@ extension EmailAuthView: View { label: authService.string.confirmPasswordFieldLabel, prompt: authService.string.confirmPasswordInputLabel, contentType: .password, - sensitive: true, + isSecureTextField: true, + validations: [ + FormValidators.confirmPassword(password: password) + ], + maintainsValidationMessage: true, onSubmit: { _ in Task { try await createUserWithEmailPassword() } }, diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift index c614c7d942..2bcf12969f 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift @@ -22,14 +22,14 @@ public struct EmailLinkView { @Environment(\.accountConflictHandler) private var accountConflictHandler @Environment(\.reportError) private var reportError @State private var email = "" - @State private var showModal = false + @State private var showAlert = false public init() {} private func sendEmailLink() async throws { do { try await authService.sendEmailSignInLink(email: email) - showModal = true + showAlert = true } catch { if let errorHandler = reportError { errorHandler(error) @@ -49,6 +49,9 @@ extension EmailLinkView: View { prompt: authService.string.emailInputLabel, keyboardType: .emailAddress, contentType: .emailAddress, + validations: [ + FormValidators.email + ], leading: { Image(systemName: "at") } @@ -71,24 +74,15 @@ extension EmailLinkView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .navigationTitle(authService.string.signInWithEmailLinkViewTitle) .safeAreaPadding() - .sheet(isPresented: $showModal) { - VStack(spacing: 24) { - Text(authService.string.signInWithEmailLinkViewMessage) - .font(.headline) - Button { - showModal = false - } label: { - Text(authService.string.okButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) + .alert( + authService.string.signInWithEmailLinkViewTitle, + isPresented: $showAlert + ) { + Button(authService.string.okButtonLabel) { + showAlert = false } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .safeAreaPadding() - .presentationDetents([.medium]) + } message: { + Text(authService.string.signInWithEmailLinkViewMessage) } .onOpenURL { url in Task { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift index 63ab35322e..27189e5994 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift @@ -38,6 +38,9 @@ struct EnterPhoneNumberView: View { prompt: authService.string.enterPhoneNumberPlaceholder, keyboardType: .phonePad, contentType: .telephoneNumber, + validations: [ + FormValidators.phoneNumber + ], onChange: { _ in } ) { CountrySelector( diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift index 7e45220a87..88adc6135d 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift @@ -49,7 +49,13 @@ struct EnterVerificationCodeView: View { .padding(.bottom) .frame(maxWidth: .infinity, alignment: .leading) - VerificationCodeInputField(code: $verificationCode) + VerificationCodeInputField( + code: $verificationCode, + validations: [ + FormValidators.verificationCode + ], + maintainsValidationMessage: true + ) Button(action: { Task { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift index 8f0185b99f..f195edabb7 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift @@ -374,6 +374,10 @@ extension MFAEnrolmentView: View { prompt: authService.string.enterPhoneNumberPrompt, keyboardType: .phonePad, contentType: .telephoneNumber, + validations: [ + FormValidators.phoneNumber + ], + maintainsValidationMessage: true, onChange: { _ in } ) { CountrySelector( @@ -388,6 +392,10 @@ extension MFAEnrolmentView: View { text: $displayName, label: authService.string.displayNameFieldLabel, prompt: authService.string.enterDisplayNameForDevicePrompt, + validations: [ + FormValidators.notEmpty(label: "Display name") + ], + maintainsValidationMessage: true, leading: { Image(systemName: "person") } @@ -430,17 +438,13 @@ extension MFAEnrolmentView: View { .multilineTextAlignment(.center) } - AuthTextField( - text: $verificationCode, - label: authService.string.verificationCodeFieldLabel, - prompt: "Enter 6-digit code", - keyboardType: .numberPad, - contentType: .oneTimeCode, - leading: { - Image(systemName: "number") - } + VerificationCodeInputField( + code: $verificationCode, + validations: [ + FormValidators.verificationCode + ], + maintainsValidationMessage: true ) - .focused($focus, equals: .verificationCode) .accessibilityIdentifier("verification-code-field") Button { @@ -579,23 +583,23 @@ extension MFAEnrolmentView: View { 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") - AuthTextField( - text: $totpCode, - label: authService.string.verificationCodeFieldLabel, - prompt: authService.string.enterCodeFromAppPrompt, - keyboardType: .numberPad, - contentType: .oneTimeCode, - leading: { - Image(systemName: "number") - } + VerificationCodeInputField( + code: $totpCode, + validations: [ + FormValidators.verificationCode + ], + maintainsValidationMessage: true ) - .focused($focus, equals: .totpCode) .accessibilityIdentifier("totp-code-field") Button { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordPromptView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordPromptView.swift index 539bf2484e..0284de7b49 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordPromptView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordPromptView.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 @@ -31,16 +32,22 @@ extension PasswordPromptSheet: View { Divider() - LabeledContent { - TextField(authService.string.passwordInputLabel, text: $password) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .submitLabel(.next) - } label: { - Image(systemName: "lock") - }.padding(.vertical, 10) - .background(Divider(), alignment: .bottom) - .padding(.bottom, 4) + AuthTextField( + text: $password, + label: authService.string.passwordFieldLabel, + prompt: authService.string.passwordInputLabel, + contentType: .password, + isSecureTextField: true, + onSubmit: { _ in + if !password.isEmpty { + coordinator.submit(password: password) + } + }, + leading: { + Image(systemName: "lock") + } + ) + .submitLabel(.next) Button(action: { coordinator.submit(password: password) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift index f14346d4d2..b8b1692f1c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift @@ -44,6 +44,9 @@ extension PasswordRecoveryView: View { prompt: authService.string.emailInputLabel, keyboardType: .emailAddress, contentType: .emailAddress, + validations: [ + FormValidators.email + ], leading: { Image(systemName: "at") } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift index 1e54cfe4d6..474e041567 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift @@ -32,10 +32,24 @@ public struct UpdatePasswordView { @Environment(AuthService.self) private var authService @State private var password = "" @State private var confirmPassword = "" + @State private var showAlert = false @FocusState private var focus: FocusableField? + private var isValid: Bool { - !password.isEmpty && password == confirmPassword + FormValidators.atLeast6Characters.isValid(input: password) && + FormValidators.confirmPassword(password: password).isValid(input: confirmPassword) + } + + private func updatePassword() { + Task { + do { + try await authService.updatePassword(to: confirmPassword) + showAlert = true + } catch { + + } + } } } @@ -48,7 +62,11 @@ extension UpdatePasswordView: View { label: "Type new password", prompt: authService.string.passwordInputLabel, contentType: .password, - sensitive: true, + isSecureTextField: true, + validations: [ + FormValidators.atLeast6Characters + ], + maintainsValidationMessage: true, leading: { Image(systemName: "lock") } @@ -61,7 +79,11 @@ extension UpdatePasswordView: View { label: "Retype new password", prompt: authService.string.confirmPasswordInputLabel, contentType: .password, - sensitive: true, + isSecureTextField: true, + validations: [ + FormValidators.confirmPassword(password: password) + ], + maintainsValidationMessage: true, leading: { Image(systemName: "lock") } @@ -70,15 +92,11 @@ extension UpdatePasswordView: View { .focused($focus, equals: .confirmPassword) Button(action: { - Task { - try await authService.updatePassword(to: confirmPassword) - authService.navigator.clear() - } + updatePassword() }, label: { Text(authService.string.updatePasswordButtonLabel) .padding(.vertical, 8) .frame(maxWidth: .infinity) - }) .disabled(!isValid) .padding([.top, .bottom], 8) @@ -88,6 +106,17 @@ extension UpdatePasswordView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .safeAreaPadding() .navigationTitle(authService.string.passwordRecoveryTitle) + .alert( + "Password Updated", + isPresented: $showAlert + ) { + Button(authService.string.okButtonLabel) { + showAlert = false + authService.navigator.clear() + } + } message: { + Text("Your password has been successfully updated.") + } .sheet(isPresented: $passwordPrompt.isPromptingPassword) { PasswordPromptSheet(coordinator: authService.passwordPrompt) } diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift index 628dd15462..5978fa6624 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift @@ -14,22 +14,11 @@ import SwiftUI -public struct FieldValidation: Identifiable, Equatable { - public let id = UUID() - public let message: String - public var valid: Bool = false - - public init(message: String, valid: Bool = false) { - self.message = message - self.valid = valid - } -} - public struct AuthTextField: View { @FocusState private var isFocused: Bool - @State var invalidInput: Bool = false @State var obscured: Bool = true - + @State var hasInteracted: Bool = false + @Binding var text: String let label: String let prompt: String @@ -37,20 +26,22 @@ public struct AuthTextField: View { var keyboardType: UIKeyboardType = .default var contentType: UITextContentType? = nil var isSecureTextField: Bool = false - var validations: [FieldValidation] = [] + var validations: [FormValidator] = [] + var maintainsValidationMessage: Bool = false var formState: ((Bool) -> Void)? = nil var onSubmit: ((String) -> Void)? = nil var onChange: ((String) -> Void)? = nil private let leading: () -> Leading? - + public init(text: Binding, label: String, prompt: String, textAlignment: TextAlignment = .leading, keyboardType: UIKeyboardType = .default, contentType: UITextContentType? = nil, - sensitive: Bool = false, - validations: [FieldValidation] = [], + isSecureTextField: Bool = false, + validations: [FormValidator] = [], + maintainsValidationMessage: Bool = false, formState: ((Bool) -> Void)? = nil, onSubmit: ((String) -> Void)? = nil, onChange: ((String) -> Void)? = nil, @@ -61,18 +52,19 @@ public struct AuthTextField: View { self.textAlignment = textAlignment self.keyboardType = keyboardType self.contentType = contentType - isSecureTextField = sensitive + self.isSecureTextField = isSecureTextField self.validations = validations + self.maintainsValidationMessage = maintainsValidationMessage self.formState = formState self.onSubmit = onSubmit self.onChange = onChange self.leading = leading } - + var allRequirementsMet: Bool { - validations.allSatisfy { $0.valid == true } + validations.allSatisfy { $0.isValid(input: text) } } - + public var body: some View { VStack(alignment: .leading) { Text(LocalizedStringResource(stringLiteral: label)) @@ -124,8 +116,16 @@ public struct AuthTextField: View { onSubmit?(text) } .onChange(of: text) { _, newValue in + if !hasInteracted { + hasInteracted = true + } onChange?(newValue) } + .onChange(of: isFocused) { _, focused in + if !focused && !text.isEmpty { + hasInteracted = true + } + } .multilineTextAlignment(textAlignment) .textFieldStyle(.plain) .padding(.vertical, 12) @@ -142,28 +142,19 @@ public struct AuthTextField: View { isFocused = true } } - if !validations.isEmpty { + if !validations.isEmpty && hasInteracted && (maintainsValidationMessage || !allRequirementsMet) { VStack(alignment: .leading, spacing: 4) { - ForEach(validations) { validation in - HStack { - Image(systemName: isSecureTextField ? "lock.open" : "x.square") - .foregroundStyle(validation.valid ? .gray : .red) - Text(validation.message) - .strikethrough(validation.valid, color: .gray) - .foregroundStyle(.gray) - .fixedSize(horizontal: false, vertical: true) - } + ForEach(validations) { validator in + let isValid = validator.isValid(input: text) + Text(validator.message) + .font(.caption) + .strikethrough(isValid, color: .gray) + .foregroundStyle(isValid ? .gray : .red) + .fixedSize(horizontal: false, vertical: true) } } .onChange(of: allRequirementsMet) { _, newValue in formState?(newValue) - if !newValue { - withAnimation(.easeInOut(duration: 0.08).repeatCount(4)) { - invalidInput = true - } completion: { - invalidInput = false - } - } } } } diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift index fc99208c02..e8b6929a05 100644 --- a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift @@ -20,12 +20,16 @@ public struct VerificationCodeInputField: View { codeLength: Int = 6, isError: Bool = false, errorMessage: String? = nil, + validations: [FormValidator] = [], + maintainsValidationMessage: Bool = false, onCodeComplete: @escaping (String) -> Void = { _ in }, onCodeChange: @escaping (String) -> Void = { _ in }) { _code = code self.codeLength = codeLength self.isError = isError self.errorMessage = errorMessage + self.validations = validations + self.maintainsValidationMessage = maintainsValidationMessage self.onCodeComplete = onCodeComplete self.onCodeChange = onCodeChange _digitFields = State(initialValue: Array(repeating: "", count: codeLength)) @@ -35,12 +39,19 @@ public struct VerificationCodeInputField: View { let codeLength: Int let isError: Bool let errorMessage: String? + let validations: [FormValidator] + let maintainsValidationMessage: Bool let onCodeComplete: (String) -> Void let onCodeChange: (String) -> Void @State private var digitFields: [String] = [] @State private var focusedIndex: Int? = nil @State private var pendingInternalCodeUpdates = 0 + @State private var hasInteracted: Bool = false + + private var allRequirementsMet: Bool { + validations.allSatisfy { $0.isValid(input: code) } + } public var body: some View { VStack(spacing: 8) { @@ -84,12 +95,29 @@ public struct VerificationCodeInputField: View { .foregroundColor(.red) .frame(maxWidth: .infinity, alignment: .leading) } + + if !validations.isEmpty && hasInteracted && (maintainsValidationMessage || !allRequirementsMet) { + VStack(alignment: .leading, spacing: 4) { + ForEach(validations) { validator in + let isValid = validator.isValid(input: code) + Text(validator.message) + .font(.caption) + .strikethrough(isValid, color: .gray) + .foregroundStyle(isValid ? .gray : .red) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } } .onAppear { // Initialize digit fields from the code binding updateDigitFieldsFromCode(shouldUpdateFocus: true, forceFocus: true) } .onChange(of: code) { _, _ in + if !hasInteracted && !code.isEmpty { + hasInteracted = true + } if pendingInternalCodeUpdates > 0 { pendingInternalCodeUpdates -= 1 return diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Validation/FormValidator.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Validation/FormValidator.swift new file mode 100644 index 0000000000..de0bdd61ec --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Validation/FormValidator.swift @@ -0,0 +1,90 @@ +// 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 Foundation + +public struct FormValidator: Identifiable { + public let id = UUID() + public let message: String + public let validate: (String?) -> Bool + + public init(message: String, validate: @escaping (String?) -> Bool) { + self.message = message + self.validate = validate + } + + public func isValid(input: String?) -> Bool { + return validate(input) + } +} + +@MainActor +public struct FormValidators { + public static let email = FormValidator( + message: "Email must contain @ and domain", + validate: { input in + guard let input else { return false } + let pattern = "^[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" + let predicate = NSPredicate(format: "SELF MATCHES %@", pattern) + return predicate.evaluate(with: input) + } + ) + + public static func confirmPassword(password: @autoclosure @escaping () -> String) -> FormValidator { + return FormValidator( + message: "Passwords must match", + validate: { input in + guard let input else { return false } + return input == password() + } + ) + } + + public static let atLeast6Characters = FormValidator( + message: "Password must be at least 6 characters", + validate: { input in + guard let input else { return false } + return input.count >= 6 + } + ) + + public static func notEmpty(label: String) -> FormValidator { + return FormValidator( + message: "\(label) cannot be empty", + validate: { input in + guard let input else { return false } + return !input.isEmpty + } + ) + } + + public static let phoneNumber = FormValidator( + message: "Phone number is not valid", + validate: { input in + guard let input else { return false } + // Basic phone number validation (digits only, at least 7 characters) + let digitsOnly = input.filter { $0.isNumber } + return digitsOnly.count >= 7 + } + ) + + public static let verificationCode = FormValidator( + message: "Verification code must be 6 digits", + validate: { input in + guard let input else { return false } + let digitsOnly = input.filter { $0.isNumber } + return digitsOnly.count == 6 + } + ) +} diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/ContentView.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/ContentView.swift index 2ffd030ffb..808f825e4a 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/ContentView.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/ContentView.swift @@ -32,9 +32,9 @@ import SwiftUI struct ContentView: View { init() { - Auth.auth().useEmulator(withHost: "localhost", port: 9099) + Auth.auth().useEmulator(withHost: "127.0.0.1", port: 9099) Auth.auth().settings?.isAppVerificationDisabledForTesting = true - Auth.auth().signInAnonymously() + //Auth.auth().signInAnonymously() let actionCodeSettings = ActionCodeSettings() actionCodeSettings.handleCodeInApp = true actionCodeSettings.url = URL(string: "https://flutterfire-e2e-tests.firebaseapp.com")