diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AccountService+Apple.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AccountService+Apple.swift index 101eee7745..1b88b113ff 100644 --- a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AccountService+Apple.swift +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AccountService+Apple.swift @@ -44,4 +44,3 @@ class AppleDeleteUserOperation: AuthenticatedOperation, } } } - diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift index 6ca6ea04cb..76dacb5baa 100644 --- a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift @@ -39,9 +39,10 @@ extension ASAuthorizationAppleIDCredential { // MARK: - Authenticate With Apple Dialog -private func authenticateWithApple( - scopes: [ASAuthorization.Scope] -) async throws -> (ASAuthorizationAppleIDCredential, String) { +private func authenticateWithApple(scopes: [ASAuthorization.Scope]) async throws -> ( + ASAuthorizationAppleIDCredential, + String +) { return try await AuthenticateWithAppleDialog(scopes: scopes).authenticate() } @@ -49,7 +50,7 @@ private class AuthenticateWithAppleDialog: NSObject { private var continuation: CheckedContinuation<(ASAuthorizationAppleIDCredential, String), Error>? private var currentNonce: String? private let scopes: [ASAuthorization.Scope] - + init(scopes: [ASAuthorization.Scope]) { self.scopes = scopes super.init() @@ -80,10 +81,8 @@ private class AuthenticateWithAppleDialog: NSObject { } extension AuthenticateWithAppleDialog: ASAuthorizationControllerDelegate { - func authorizationController( - controller: ASAuthorizationController, - didCompleteWithAuthorization authorization: ASAuthorization - ) { + func authorizationController(controller _: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization) { if let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential { if let nonce = currentNonce { continuation?.resume(returning: (appleIDCredential, nonce)) @@ -106,10 +105,8 @@ extension AuthenticateWithAppleDialog: ASAuthorizationControllerDelegate { continuation = nil } - func authorizationController( - controller: ASAuthorizationController, - didCompleteWithError error: Error - ) { + func authorizationController(controller _: ASAuthorizationController, + didCompleteWithError error: Error) { continuation?.resume(throwing: AuthServiceError.signInFailed(underlying: error)) continuation = nil } @@ -160,4 +157,3 @@ public class AppleProviderAuthUI: AuthProviderUI { AnyView(SignInWithAppleButton(provider: provider)) } } - diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AuthService+Apple.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AuthService+Apple.swift index 57e6e11576..43ee3773a0 100644 --- a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AuthService+Apple.swift +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AuthService+Apple.swift @@ -29,4 +29,3 @@ public extension AuthService { return self } } - diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/CryptoUtils.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/CryptoUtils.swift index d09fc9bf65..b96bac7887 100644 --- a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/CryptoUtils.swift +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/CryptoUtils.swift @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import Foundation import CryptoKit +import Foundation /// Set of utility APIs for generating cryptographical artifacts. enum CryptoUtils { @@ -50,4 +50,3 @@ enum CryptoUtils { return hashString } } - diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift index df6f4b40f5..693491dbfe 100644 --- a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift @@ -29,7 +29,7 @@ extension SignInWithAppleButton: View { public var body: some View { Button(action: { Task { - try await authService.signIn(provider) + try? await authService.signIn(provider) } }) { HStack { @@ -51,4 +51,3 @@ extension SignInWithAppleButton: View { .accessibilityIdentifier("sign-in-with-apple-button") } } - diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Tests/FirebaseAppleSwiftUITests/FirebaseAppleSwiftUITests.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Tests/FirebaseAppleSwiftUITests/FirebaseAppleSwiftUITests.swift index 03a8b65f07..abbc709cd6 100644 --- a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Tests/FirebaseAppleSwiftUITests/FirebaseAppleSwiftUITests.swift +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Tests/FirebaseAppleSwiftUITests/FirebaseAppleSwiftUITests.swift @@ -18,4 +18,3 @@ import Testing @Test func example() async throws { // Write your test here and use APIs like `#expect(...)` to check expected conditions. } - diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Auth/MultiFactor.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Auth/MultiFactor.swift index 02eb20200b..e2be260f56 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Auth/MultiFactor.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Auth/MultiFactor.swift @@ -57,7 +57,7 @@ public struct EnrollmentSession { public let expiresAt: Date // Internal handle to finish TOTP - internal let _totpSecret: AnyObject? + let _totpSecret: AnyObject? public enum EnrollmentStatus { case initiated @@ -111,4 +111,4 @@ public struct MFARequired { public init(hints: [MFAHint]) { self.hints = hints } -} \ No newline at end of file +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift index 836ee116d4..fe8970c91a 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift @@ -39,7 +39,6 @@ public enum AuthServiceError: LocalizedError { case invalidPhoneAuthenticationArguments(String) case providerNotFound(String) case multiFactorAuth(String) - public var errorDescription: String? { switch self { @@ -62,7 +61,7 @@ public enum AuthServiceError: LocalizedError { case let .providerNotFound(description): return description case let .invalidPhoneAuthenticationArguments(description): - return description + return description case let .multiFactorAuth(description): return description } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift index 918140bf24..d760cc743f 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift @@ -25,7 +25,7 @@ public struct AuthConfiguration { public let emailLinkSignInActionCodeSettings: ActionCodeSettings? public let verifyEmailActionCodeSettings: ActionCodeSettings? - // MARK: - MFA Configuration + // MARK: - MFA Configuration public let mfaEnabled: Bool public let allowedSecondFactors: Set diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 2f6bfcf472..4c124a695c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -103,7 +103,7 @@ public final class AuthService { public var currentUser: User? public var authenticationState: AuthenticationState = .unauthenticated public var authenticationFlow: AuthenticationFlow = .signIn - public var errorMessage = "" + public var currentError: AlertError? public let passwordPrompt: PasswordPromptCoordinator = .init() public var currentMFARequired: MFARequired? private var currentMFAResolver: MultiFactorResolver? @@ -132,9 +132,14 @@ public final class AuthService { } public func signIn(_ provider: AuthProviderSwift) async throws -> SignInOutcome { - let credential = try await provider.createAuthCredential() - let result = try await signIn(credentials: credential) - return result + do { + let credential = try await provider.createAuthCredential() + let result = try await signIn(credentials: credential) + return result + } catch { + updateError(message: string.localizedErrorMessage(for: error)) + throw error + } } // MARK: - End Provider APIs @@ -160,7 +165,11 @@ public final class AuthService { } func reset() { - errorMessage = "" + currentError = nil + } + + func updateError(title: String = "Error", message: String) { + currentError = AlertError(title: title, message: message) } public var shouldHandleAnonymousUpgrade: Bool { @@ -172,9 +181,7 @@ public final class AuthService { try await auth.signOut() updateAuthenticationState() } catch { - errorMessage = string.localizedErrorMessage( - for: error - ) + updateError(message: string.localizedErrorMessage(for: error)) throw error } } @@ -186,14 +193,13 @@ public final class AuthService { updateAuthenticationState() } catch { authenticationState = .unauthenticated - errorMessage = string.localizedErrorMessage( - for: error - ) + updateError(message: string.localizedErrorMessage(for: error)) throw error } } - public func handleAutoUpgradeAnonymousUser(credentials: AuthCredential) async throws -> SignInOutcome { + public func handleAutoUpgradeAnonymousUser(credentials: AuthCredential) async throws + -> SignInOutcome { if currentUser == nil { throw AuthServiceError.noCurrentUser } @@ -227,9 +233,9 @@ public final class AuthService { updateAuthenticationState() return .signedIn(result) } - } catch let error as NSError { + } catch let error as NSError { authenticationState = .unauthenticated - errorMessage = string.localizedErrorMessage(for: error) + updateError(message: string.localizedErrorMessage(for: error)) // Check if this is an MFA required error if error.code == AuthErrorCode.secondFactorRequired.rawValue { @@ -260,9 +266,7 @@ public final class AuthService { } } } catch { - errorMessage = string.localizedErrorMessage( - for: error - ) + updateError(message: string.localizedErrorMessage(for: error)) throw error } } @@ -283,14 +287,12 @@ public extension AuthService { let provider = matchingProvider.provider as? DeleteUserSwift else { throw AuthServiceError.providerNotFound("No provider found for \(providerId)") } - + try await provider.deleteUser(user: user) } } } catch { - errorMessage = string.localizedErrorMessage( - for: error - ) + updateError(message: string.localizedErrorMessage(for: error)) throw error } } @@ -306,9 +308,7 @@ public extension AuthService { } } catch { - errorMessage = string.localizedErrorMessage( - for: error - ) + updateError(message: string.localizedErrorMessage(for: error)) throw error } } @@ -342,9 +342,7 @@ public extension AuthService { } } catch { authenticationState = .unauthenticated - errorMessage = string.localizedErrorMessage( - for: error - ) + updateError(message: string.localizedErrorMessage(for: error)) throw error } } @@ -353,9 +351,7 @@ public extension AuthService { do { try await auth.sendPasswordReset(withEmail: email) } catch { - errorMessage = string.localizedErrorMessage( - for: error - ) + updateError(message: string.localizedErrorMessage(for: error)) throw error } } @@ -372,9 +368,7 @@ public extension AuthService { actionCodeSettings: actionCodeSettings ) } catch { - errorMessage = string.localizedErrorMessage( - for: error - ) + updateError(message: string.localizedErrorMessage(for: error)) throw error } } @@ -407,9 +401,7 @@ public extension AuthService { emailLink = nil } } catch { - errorMessage = string.localizedErrorMessage( - for: error - ) + updateError(message: string.localizedErrorMessage(for: error)) throw error } } @@ -442,7 +434,6 @@ public extension AuthService { } } - // MARK: - Phone Auth Sign In public extension AuthService { @@ -473,28 +464,28 @@ public extension AuthService { guard let user = currentUser else { throw AuthServiceError.noCurrentUser } - + do { let changeRequest = user.createProfileChangeRequest() changeRequest.photoURL = url try await changeRequest.commitChanges() } catch { - errorMessage = string.localizedErrorMessage(for: error) + updateError(message: string.localizedErrorMessage(for: error)) throw error } } - + func updateUserDisplayName(name: String) async throws { guard let user = currentUser else { throw AuthServiceError.noCurrentUser } - + do { let changeRequest = user.createProfileChangeRequest() changeRequest.displayName = name try await changeRequest.commitChanges() } catch { - errorMessage = string.localizedErrorMessage(for: error) + updateError(message: string.localizedErrorMessage(for: error)) throw error } } @@ -505,197 +496,219 @@ public extension AuthService { public extension AuthService { func startMfaEnrollment(type: SecondFactorType, accountName: String? = nil, issuer: String? = nil) async throws -> EnrollmentSession { - guard let user = auth.currentUser else { - throw AuthServiceError.noCurrentUser - } + do { + guard let user = auth.currentUser else { + throw AuthServiceError.noCurrentUser + } - // Check if MFA is enabled in configuration - guard configuration.mfaEnabled else { - throw AuthServiceError.multiFactorAuth("MFA is not enabled in configuration, please enable `AuthConfiguration.mfaEnabled`") - } + // Check if MFA is enabled in configuration + guard configuration.mfaEnabled else { + throw AuthServiceError + .multiFactorAuth( + "MFA is not enabled in configuration, please enable `AuthConfiguration.mfaEnabled`" + ) + } - // Check if the requested factor type is allowed - guard configuration.allowedSecondFactors.contains(type) else { - throw AuthServiceError - .multiFactorAuth( - "The requested MFA factor type '\(type)' is not allowed in AuthConfiguration.allowedSecondFactors" - ) - } + // Check if the requested factor type is allowed + guard configuration.allowedSecondFactors.contains(type) else { + throw AuthServiceError + .multiFactorAuth( + "The requested MFA factor type '\(type)' is not allowed in AuthConfiguration.allowedSecondFactors" + ) + } - let multiFactorUser = user.multiFactor + let multiFactorUser = user.multiFactor - // Get the multi-factor session - let session = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< - MultiFactorSession, - Error - >) in - multiFactorUser.getSessionWithCompletion { session, error in - if let error = error { - continuation.resume(throwing: error) - } else if let session = session { - continuation.resume(returning: session) - } else { - continuation.resume(throwing: AuthServiceError.multiFactorAuth("Failed to get MFA session for '\(type)'")) + // Get the multi-factor session + let session = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + MultiFactorSession, + Error + >) in + multiFactorUser.getSessionWithCompletion { session, error in + if let error = error { + continuation.resume(throwing: error) + } else if let session = session { + continuation.resume(returning: session) + } else { + continuation + .resume(throwing: AuthServiceError + .multiFactorAuth("Failed to get MFA session for '\(type)'")) + } } } - } - switch type { - case .sms: - // For SMS, we just return the session - phone number will be provided in - // sendSmsVerificationForEnrollment - return EnrollmentSession( - type: .sms, - session: session, - status: .initiated - ) + switch type { + case .sms: + // For SMS, we just return the session - phone number will be provided in + // sendSmsVerificationForEnrollment + return EnrollmentSession( + type: .sms, + session: session, + status: .initiated + ) - case .totp: - // For TOTP, generate the secret and QR code - let totpSecret = try await TOTPMultiFactorGenerator.generateSecret(with: session) + case .totp: + // For TOTP, generate the secret and QR code + let totpSecret = try await TOTPMultiFactorGenerator.generateSecret(with: session) - // Generate QR code URL - let resolvedAccountName = accountName ?? user.email ?? "User" - let resolvedIssuer = issuer ?? configuration.mfaIssuer + // Generate QR code URL + let resolvedAccountName = accountName ?? user.email ?? "User" + let resolvedIssuer = issuer ?? configuration.mfaIssuer - let qrCodeURL = totpSecret.generateQRCodeURL( - withAccountName: resolvedAccountName, - issuer: resolvedIssuer - ) + let qrCodeURL = totpSecret.generateQRCodeURL( + withAccountName: resolvedAccountName, + issuer: resolvedIssuer + ) - let totpInfo = TOTPEnrollmentInfo( - sharedSecretKey: totpSecret.sharedSecretKey(), - qrCodeURL: URL(string: qrCodeURL), - accountName: resolvedAccountName, - issuer: resolvedIssuer, - verificationStatus: .pending - ) + let totpInfo = TOTPEnrollmentInfo( + sharedSecretKey: totpSecret.sharedSecretKey(), + qrCodeURL: URL(string: qrCodeURL), + accountName: resolvedAccountName, + issuer: resolvedIssuer, + verificationStatus: .pending + ) - return EnrollmentSession( - type: .totp, - session: session, - totpInfo: totpInfo, - status: .initiated, - _totpSecret: totpSecret - ) + return EnrollmentSession( + type: .totp, + session: session, + totpInfo: totpInfo, + status: .initiated, + _totpSecret: totpSecret + ) + } + } catch { + updateError(message: string.localizedErrorMessage(for: error)) + throw error } } func sendSmsVerificationForEnrollment(session: EnrollmentSession, phoneNumber: String) async throws -> String { - // Validate session - guard session.type == .sms else { - throw AuthServiceError.multiFactorAuth("Session is not configured for SMS enrollment") - } + do { + // Validate session + guard session.type == .sms else { + throw AuthServiceError.multiFactorAuth("Session is not configured for SMS enrollment") + } - guard session.canProceed else { - if session.isExpired { - throw AuthServiceError.multiFactorAuth("Enrollment session has expired") - } else { - throw AuthServiceError - .multiFactorAuth("Session is not in a valid state for SMS verification") + guard session.canProceed else { + if session.isExpired { + throw AuthServiceError.multiFactorAuth("Enrollment session has expired") + } else { + throw AuthServiceError + .multiFactorAuth("Session is not in a valid state for SMS verification") + } } - } - // Validate phone number format - guard !phoneNumber.isEmpty else { - throw AuthServiceError.multiFactorAuth("Phone number cannot be empty for SMS enrollment") - } + // Validate phone number format + guard !phoneNumber.isEmpty else { + throw AuthServiceError.multiFactorAuth("Phone number cannot be empty for SMS enrollment") + } - // Send SMS verification using Firebase Auth PhoneAuthProvider - let verificationID = - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< - String, - Error - >) in - PhoneAuthProvider.provider().verifyPhoneNumber( - phoneNumber, - uiDelegate: nil, - multiFactorSession: session.session - ) { verificationID, error in - if let error = error { - continuation.resume(throwing: error) - } else if let verificationID = verificationID { - continuation.resume(returning: verificationID) - } else { - continuation - .resume(throwing: AuthServiceError - .multiFactorAuth("Failed to send SMS verification code to verify phone number")) + // Send SMS verification using Firebase Auth PhoneAuthProvider + let verificationID = + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + String, + Error + >) in + PhoneAuthProvider.provider().verifyPhoneNumber( + phoneNumber, + uiDelegate: nil, + multiFactorSession: session.session + ) { verificationID, error in + if let error = error { + continuation.resume(throwing: error) + } else if let verificationID = verificationID { + continuation.resume(returning: verificationID) + } else { + continuation + .resume(throwing: AuthServiceError + .multiFactorAuth("Failed to send SMS verification code to verify phone number")) + } } } - } - return verificationID + return verificationID + } catch { + updateError(message: string.localizedErrorMessage(for: error)) + throw error + } } func completeEnrollment(session: EnrollmentSession, verificationId: String?, verificationCode: String, displayName: String) async throws { - // Validate session state - guard session.canProceed else { - if session.isExpired { - throw AuthServiceError.multiFactorAuth("Enrollment session has expired, cannot complete enrollment") - } else { - throw AuthServiceError.multiFactorAuth("Enrollment session is not in a valid state for completion") + do { + // Validate session state + guard session.canProceed else { + if session.isExpired { + throw AuthServiceError + .multiFactorAuth("Enrollment session has expired, cannot complete enrollment") + } else { + throw AuthServiceError + .multiFactorAuth("Enrollment session is not in a valid state for completion") + } } - } - // Validate verification code - guard !verificationCode.isEmpty else { - throw AuthServiceError.multiFactorAuth("Verification code cannot be empty") - } + // Validate verification code + guard !verificationCode.isEmpty else { + throw AuthServiceError.multiFactorAuth("Verification code cannot be empty") + } - guard let user = auth.currentUser else { - throw AuthServiceError.noCurrentUser - } + guard let user = auth.currentUser else { + throw AuthServiceError.noCurrentUser + } - let multiFactorUser = user.multiFactor + let multiFactorUser = user.multiFactor - // Create the appropriate assertion based on factor type - let assertion: MultiFactorAssertion + // Create the appropriate assertion based on factor type + let assertion: MultiFactorAssertion - switch session.type { - case .sms: - // For SMS, we need the verification ID - guard let verificationId = verificationId else { - throw AuthServiceError - .multiFactorAuth("Verification ID is required for SMS enrollment") - } + switch session.type { + case .sms: + // For SMS, we need the verification ID + guard let verificationId = verificationId else { + throw AuthServiceError + .multiFactorAuth("Verification ID is required for SMS enrollment") + } - // Create phone credential and assertion - let credential = PhoneAuthProvider.provider().credential( - withVerificationID: verificationId, - verificationCode: verificationCode - ) - assertion = PhoneMultiFactorGenerator.assertion(with: credential) + // Create phone credential and assertion + let credential = PhoneAuthProvider.provider().credential( + withVerificationID: verificationId, + verificationCode: verificationCode + ) + assertion = PhoneMultiFactorGenerator.assertion(with: credential) - case .totp: - // For TOTP, we need the secret from the session - guard let totpInfo = session.totpInfo else { - throw AuthServiceError - .multiFactorAuth("TOTP info is missing from enrollment session") - } + case .totp: + // For TOTP, we need the secret from the session + guard let totpInfo = session.totpInfo else { + throw AuthServiceError + .multiFactorAuth("TOTP info is missing from enrollment session") + } - // Use the stored TOTP secret from the enrollment session - guard let secret = session._totpSecret else { - throw AuthServiceError - .multiFactorAuth("TOTP secret is missing from enrollment session") - } + // Use the stored TOTP secret from the enrollment session + guard let secret = session._totpSecret else { + throw AuthServiceError + .multiFactorAuth("TOTP secret is missing from enrollment session") + } - // The concrete type is FirebaseAuth.TOTPSecret (kept as AnyObject to avoid exposing it) - guard let totpSecret = secret as? TOTPSecret else { - throw AuthServiceError - .multiFactorAuth("Invalid TOTP secret type in enrollment session") + // The concrete type is FirebaseAuth.TOTPSecret (kept as AnyObject to avoid exposing it) + guard let totpSecret = secret as? TOTPSecret else { + throw AuthServiceError + .multiFactorAuth("Invalid TOTP secret type in enrollment session") + } + + assertion = TOTPMultiFactorGenerator.assertionForEnrollment( + with: totpSecret, + oneTimePassword: verificationCode + ) } - assertion = TOTPMultiFactorGenerator.assertionForEnrollment( - with: totpSecret, - oneTimePassword: verificationCode - ) + // Complete the enrollment + try await user.multiFactor.enroll(with: assertion, displayName: displayName) + currentUser = auth.currentUser + } catch { + updateError(message: string.localizedErrorMessage(for: error)) + throw error } - - // Complete the enrollment - try await user.multiFactor.enroll(with: assertion, displayName: displayName) - currentUser = auth.currentUser } func reauthenticateCurrentUser(on user: User) async throws { @@ -703,7 +716,7 @@ public extension AuthService { throw AuthServiceError .reauthenticationRequired("Recent login required to perform this operation.") } - + if providerId == EmailAuthProviderID { guard let email = user.email else { throw AuthServiceError.invalidCredentials("User does not have an email address") @@ -720,33 +733,38 @@ public extension AuthService { } func unenrollMFA(_ factorUid: String) async throws -> [MultiFactorInfo] { - guard let user = auth.currentUser else { - throw AuthServiceError.noCurrentUser - } + do { + guard let user = auth.currentUser else { + throw AuthServiceError.noCurrentUser + } - let multiFactorUser = user.multiFactor + let multiFactorUser = user.multiFactor - do { - try await multiFactorUser.unenroll(withFactorUID: factorUid) - } catch let error as NSError { - if error.domain == AuthErrorDomain, - error.code == AuthErrorCode.requiresRecentLogin.rawValue || error.code == AuthErrorCode - .userTokenExpired.rawValue { - try await reauthenticateCurrentUser(on: user) + do { try await multiFactorUser.unenroll(withFactorUID: factorUid) - } else { - throw AuthServiceError - .multiFactorAuth( - "Invalid second factor: \(error.localizedDescription)" - ) + } catch let error as NSError { + if error.domain == AuthErrorDomain, + error.code == AuthErrorCode.requiresRecentLogin.rawValue || error.code == AuthErrorCode + .userTokenExpired.rawValue { + try await reauthenticateCurrentUser(on: user) + try await multiFactorUser.unenroll(withFactorUID: factorUid) + } else { + throw AuthServiceError + .multiFactorAuth( + "Invalid second factor: \(error.localizedDescription)" + ) + } } - } - // This is the only we to get the actual latest enrolledFactors - currentUser = Auth.auth().currentUser - let freshFactors = currentUser?.multiFactor.enrolledFactors ?? [] + // This is the only we to get the actual latest enrolledFactors + currentUser = Auth.auth().currentUser + let freshFactors = currentUser?.multiFactor.enrolledFactors ?? [] - return freshFactors + return freshFactors + } catch { + updateError(message: string.localizedErrorMessage(for: error)) + throw error + } } // MARK: - MFA Helper Methods @@ -781,89 +799,99 @@ public extension AuthService { } func resolveSmsChallenge(hintIndex: Int) async throws -> String { - guard let resolver = currentMFAResolver else { - throw AuthServiceError.multiFactorAuth("No MFA resolver available") - } + do { + guard let resolver = currentMFAResolver else { + throw AuthServiceError.multiFactorAuth("No MFA resolver available") + } - guard hintIndex < resolver.hints.count else { - throw AuthServiceError.multiFactorAuth("Invalid hint index") - } + guard hintIndex < resolver.hints.count else { + throw AuthServiceError.multiFactorAuth("Invalid hint index") + } - let hint = resolver.hints[hintIndex] - guard hint.factorID == PhoneMultiFactorID else { - throw AuthServiceError.multiFactorAuth("Selected hint is not a phone hint") - } - let phoneHint = hint as! PhoneMultiFactorInfo + let hint = resolver.hints[hintIndex] + guard hint.factorID == PhoneMultiFactorID else { + throw AuthServiceError.multiFactorAuth("Selected hint is not a phone hint") + } + let phoneHint = hint as! PhoneMultiFactorInfo - return try await withCheckedThrowingContinuation { continuation in - PhoneAuthProvider.provider().verifyPhoneNumber( - with: phoneHint, - uiDelegate: nil, - multiFactorSession: resolver.session - ) { verificationId, error in - if let error = error { - continuation - .resume(throwing: AuthServiceError.multiFactorAuth(error.localizedDescription)) - } else if let verificationId = verificationId { - continuation.resume(returning: verificationId) - } else { - continuation - .resume(throwing: AuthServiceError.multiFactorAuth("Unknown error occurred")) + return try await withCheckedThrowingContinuation { continuation in + PhoneAuthProvider.provider().verifyPhoneNumber( + with: phoneHint, + uiDelegate: nil, + multiFactorSession: resolver.session + ) { verificationId, error in + if let error = error { + continuation + .resume(throwing: AuthServiceError.multiFactorAuth(error.localizedDescription)) + } else if let verificationId = verificationId { + continuation.resume(returning: verificationId) + } else { + continuation + .resume(throwing: AuthServiceError.multiFactorAuth("Unknown error occurred")) + } } } + } catch { + updateError(message: string.localizedErrorMessage(for: error)) + throw error } } func resolveSignIn(code: String, hintIndex: Int, verificationId: String? = nil) async throws { - guard let resolver = currentMFAResolver else { - throw AuthServiceError.multiFactorAuth("No MFA resolver available") - } + do { + guard let resolver = currentMFAResolver else { + throw AuthServiceError.multiFactorAuth("No MFA resolver available") + } - guard hintIndex < resolver.hints.count else { - throw AuthServiceError.multiFactorAuth("Invalid hint index") - } + guard hintIndex < resolver.hints.count else { + throw AuthServiceError.multiFactorAuth("Invalid hint index") + } - let hint = resolver.hints[hintIndex] - let assertion: MultiFactorAssertion + let hint = resolver.hints[hintIndex] + let assertion: MultiFactorAssertion - // Create the appropriate assertion based on the hint type - if hint.factorID == PhoneMultiFactorID { - guard let verificationId = verificationId else { - throw AuthServiceError.multiFactorAuth("Verification ID is required for SMS MFA") - } + // Create the appropriate assertion based on the hint type + if hint.factorID == PhoneMultiFactorID { + guard let verificationId = verificationId else { + throw AuthServiceError.multiFactorAuth("Verification ID is required for SMS MFA") + } - let credential = PhoneAuthProvider.provider().credential( - withVerificationID: verificationId, - verificationCode: code - ) - assertion = PhoneMultiFactorGenerator.assertion(with: credential) + let credential = PhoneAuthProvider.provider().credential( + withVerificationID: verificationId, + verificationCode: code + ) + assertion = PhoneMultiFactorGenerator.assertion(with: credential) - } else if hint.factorID == TOTPMultiFactorID { - assertion = TOTPMultiFactorGenerator.assertionForSignIn( - withEnrollmentID: hint.uid, - oneTimePassword: code - ) + } else if hint.factorID == TOTPMultiFactorID { + assertion = TOTPMultiFactorGenerator.assertionForSignIn( + withEnrollmentID: hint.uid, + oneTimePassword: code + ) - } else { - throw AuthServiceError.multiFactorAuth("Unsupported MFA hint type") - } + } else { + throw AuthServiceError.multiFactorAuth("Unsupported MFA hint type") + } - do { - let result = try await resolver.resolveSignIn(with: assertion) - - // After MFA resolution, result.credential is nil, so restore the original credential - // that was used before MFA was triggered - signedInCredential = result.credential ?? pendingMFACredential - updateAuthenticationState() + do { + let result = try await resolver.resolveSignIn(with: assertion) + + // After MFA resolution, result.credential is nil, so restore the original credential + // that was used before MFA was triggered + signedInCredential = result.credential ?? pendingMFACredential + updateAuthenticationState() - // Clear MFA resolution state - currentMFARequired = nil - currentMFAResolver = nil - pendingMFACredential = nil + // Clear MFA resolution state + currentMFARequired = nil + currentMFAResolver = nil + pendingMFACredential = nil + } catch { + throw AuthServiceError + .multiFactorAuth("Failed to resolve MFA challenge: \(error.localizedDescription)") + } } catch { - throw AuthServiceError - .multiFactorAuth("Failed to resolve MFA challenge: \(error.localizedDescription)") + updateError(message: string.localizedErrorMessage(for: error)) + throw error } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 1ba4f62146..20220a0d5b 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -60,7 +60,7 @@ extension AuthPickerView: View { case .mfaEnrollment: MFAEnrolmentView() case .mfaResolution: - MFAResolutionView() + MFAResolutionView() case .authPicker: if authService.emailSignInEnabled { Text(authService.authenticationFlow == .signIn ? authService.string @@ -90,7 +90,6 @@ extension AuthPickerView: View { } } PrivacyTOCsView(displayMode: .footer) - Text(authService.errorMessage).foregroundColor(.red) default: // TODO: - possibly refactor this, see: https://github.com/firebase/FirebaseUI-iOS/pull/1259#discussion_r2105473437 EmptyView() @@ -98,6 +97,10 @@ extension AuthPickerView: View { } } } + .errorAlert(error: Binding( + get: { authService.currentError }, + set: { authService.currentError = $0 } + ), okButtonLabel: authService.string.okButtonLabel) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index c79541a3ab..c11f506f6a 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -49,15 +49,11 @@ public struct EmailAuthView { } private func signInWithEmailPassword() async { - do { - try await authService.signIn(email: email, password: password) - } catch {} + try? await authService.signIn(email: email, password: password) } private func createUserWithEmailPassword() async { - do { - try await authService.createUser(email: email, password: password) - } catch {} + try? await authService.createUser(email: email, password: password) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift index e753bcd7ad..87b761696d 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift @@ -27,7 +27,9 @@ public struct EmailLinkView { do { try await authService.sendEmailSignInLink(email: email) showModal = true - } catch {} + } catch { + // Error already displayed via modal by AuthService + } } } @@ -78,9 +80,9 @@ extension EmailLinkView: View { .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .buttonStyle(.borderedProminent) - Text(authService.errorMessage).foregroundColor(.red) Spacer() - }.sheet(isPresented: $showModal) { + } + .sheet(isPresented: $showModal) { VStack { Text(authService.string.signInWithEmailLinkViewMessage) .padding() @@ -90,11 +92,10 @@ extension EmailLinkView: View { .padding() } .padding() - }.onOpenURL { url in + } + .onOpenURL { url in Task { - do { - try await authService.handleSignInLink(url: url) - } catch {} + try? await authService.handleSignInLink(url: url) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift new file mode 100644 index 0000000000..806fa015b3 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift @@ -0,0 +1,56 @@ +// 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 + +/// A reusable view modifier that displays error messages in an alert modal +struct ErrorAlertModifier: ViewModifier { + @Binding var error: AlertError? + let okButtonLabel: String + + func body(content: Content) -> some View { + content + .alert(isPresented: Binding( + get: { error != nil }, + set: { if !$0 { error = nil } } + )) { + Alert( + title: Text(error?.title ?? "Error"), + message: Text(error?.message ?? ""), + dismissButton: .default(Text(okButtonLabel)) { + error = nil + } + ) + } + } +} + +/// Extension to make it easy to apply the error alert modifier +public extension View { + func errorAlert(error: Binding, okButtonLabel: String = "OK") -> some View { + modifier(ErrorAlertModifier(error: error, okButtonLabel: okButtonLabel)) + } +} + +/// A struct to represent an error that should be displayed in an alert +public struct AlertError: Identifiable { + public let id = UUID() + public let title: String + public let message: String + + public init(title: String = "Error", message: String) { + self.title = title + self.message = message + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift index 680462e780..0f54756425 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift @@ -32,7 +32,6 @@ public struct MFAEnrolmentView { @State private var totpCode = "" @State private var currentSession: EnrollmentSession? @State private var isLoading = false - @State private var errorMessage = "" @State private var displayName = "" @State private var showCopiedFeedback = false @@ -78,20 +77,14 @@ public struct MFAEnrolmentView { private func startEnrollment() { Task { isLoading = true - errorMessage = "" - - do { - let session = try await authService.startMfaEnrollment( - type: selectedFactorType, - accountName: authService.currentUser?.email, - issuer: authService.configuration.mfaIssuer - ) - currentSession = session - } catch { - errorMessage = error.localizedDescription - } - - isLoading = false + defer { isLoading = false } + + let session = try await authService.startMfaEnrollment( + type: selectedFactorType, + accountName: authService.currentUser?.email, + issuer: authService.configuration.mfaIssuer + ) + currentSession = session } } @@ -100,30 +93,24 @@ public struct MFAEnrolmentView { Task { isLoading = true - errorMessage = "" - - do { - let verificationId = try await authService.sendSmsVerificationForEnrollment( - session: session, - phoneNumber: phoneNumber - ) - // Update session status - currentSession = EnrollmentSession( - id: session.id, - type: session.type, - session: session.session, - totpInfo: session.totpInfo, - phoneNumber: phoneNumber, - verificationId: verificationId, - status: .verificationSent, - createdAt: session.createdAt, - expiresAt: session.expiresAt - ) - } catch { - errorMessage = error.localizedDescription - } - - isLoading = false + defer { isLoading = false } + + let verificationId = try await authService.sendSmsVerificationForEnrollment( + session: session, + phoneNumber: phoneNumber + ) + // Update session status + currentSession = EnrollmentSession( + id: session.id, + type: session.type, + session: session.session, + totpInfo: session.totpInfo, + phoneNumber: phoneNumber, + verificationId: verificationId, + status: .verificationSent, + createdAt: session.createdAt, + expiresAt: session.expiresAt + ) } } @@ -132,28 +119,20 @@ public struct MFAEnrolmentView { Task { isLoading = true - errorMessage = "" - - do { - let code = session.type == .sms ? verificationCode : totpCode - try await authService.completeEnrollment( - session: session, - verificationId: session.verificationId, - verificationCode: code, - displayName: displayName - ) + defer { isLoading = false } - // Reset form state on success - resetForm() + let code = session.type == .sms ? verificationCode : totpCode + try await authService.completeEnrollment( + session: session, + verificationId: session.verificationId, + verificationCode: code, + displayName: displayName + ) - // Navigate back to signed in view - authService.authView = .authPicker + // Reset form state on success + resetForm() - } catch { - errorMessage = error.localizedDescription - } - - isLoading = false + authService.authView = .authPicker } } @@ -163,7 +142,6 @@ public struct MFAEnrolmentView { verificationCode = "" totpCode = "" displayName = "" - errorMessage = "" focus = nil } @@ -174,7 +152,6 @@ public struct MFAEnrolmentView { private func copyToClipboard(_ text: String) { UIPasteboard.general.string = text - // Show feedback showCopiedFeedback = true @@ -185,25 +162,25 @@ public struct MFAEnrolmentView { 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) } } @@ -309,15 +286,6 @@ extension MFAEnrolmentView: View { } else { initialContent } - - // Error message - if !errorMessage.isEmpty { - Text(errorMessage) - .foregroundColor(.red) - .font(.caption) - .padding(.horizontal) - .accessibilityIdentifier("error-message") - } } .padding(.horizontal, 16) .padding(.vertical, 20) @@ -532,7 +500,7 @@ extension MFAEnrolmentView: View { .aspectRatio(contentMode: .fit) .frame(width: 200, height: 200) .accessibilityIdentifier("qr-code-image") - + HStack(spacing: 6) { Image(systemName: "arrow.up.forward.app.fill") .font(.caption) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift index e3f41dd399..ab3cc74b94 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift @@ -25,7 +25,6 @@ public struct MFAManagementView { @State private var enrolledFactors: [MultiFactorInfo] = [] @State private var isLoading = false - @State private var errorMessage = "" // Present password prompt when required for reauthentication private var isShowingPasswordPrompt: Binding { @@ -45,16 +44,14 @@ public struct MFAManagementView { private func unenrollFactor(_ factorUid: String) { Task { isLoading = true - errorMessage = "" do { let freshFactors = try await authService.unenrollMFA(factorUid) enrolledFactors = freshFactors + isLoading = false } catch { - errorMessage = error.localizedDescription + isLoading = false } - - isLoading = false } } @@ -151,15 +148,6 @@ extension MFAManagementView: View { } } - // Error message - if !errorMessage.isEmpty { - Text(errorMessage) - .foregroundColor(.red) - .font(.caption) - .padding(.horizontal) - .accessibilityIdentifier("error-message") - } - Spacer() } .onAppear { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift index a09ade41d6..800a7c5131 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseCore import FirebaseAuth +import FirebaseCore import SwiftUI private enum FocusableField: Hashable { @@ -28,7 +28,6 @@ public struct MFAResolutionView { @State private var verificationCode = "" @State private var totpCode = "" @State private var isLoading = false - @State private var errorMessage = "" @State private var selectedHintIndex = 0 @State private var verificationId: String? @@ -67,23 +66,20 @@ public struct MFAResolutionView { Task { isLoading = true - errorMessage = "" do { let verificationId = try await authService.resolveSmsChallenge(hintIndex: selectedHintIndex) self.verificationId = verificationId + isLoading = false } catch { - errorMessage = error.localizedDescription + isLoading = false } - - isLoading = false } } private func completeResolution() { Task { isLoading = true - errorMessage = "" do { let code = selectedHint?.isPhoneHint == true ? verificationCode : totpCode @@ -95,11 +91,10 @@ public struct MFAResolutionView { // On success, the AuthService will update the authentication state // and we should navigate back to the main app authService.authView = .authPicker + isLoading = false } catch { - errorMessage = error.localizedDescription + isLoading = false } - - isLoading = false } } @@ -111,75 +106,66 @@ public struct MFAResolutionView { extension MFAResolutionView: View { public var body: some View { VStack(spacing: 24) { - // Header - VStack(spacing: 12) { - Image(systemName: "lock.shield") - .font(.system(size: 50)) - .foregroundColor(.blue) + // Header + VStack(spacing: 12) { + Image(systemName: "lock.shield") + .font(.system(size: 50)) + .foregroundColor(.blue) - Text("Two-Factor Authentication") - .font(.largeTitle) - .fontWeight(.bold) - .accessibilityIdentifier("mfa-resolution-title") + Text("Two-Factor Authentication") + .font(.largeTitle) + .fontWeight(.bold) + .accessibilityIdentifier("mfa-resolution-title") - Text("Complete sign-in with your second factor") - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .padding(.horizontal) + Text("Complete sign-in with your second factor") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal) - // MFA Hints Selection (if multiple available) - if let mfaRequired = mfaRequired, mfaRequired.hints.count > 1 { - mfaHintsSelectionView(mfaRequired: mfaRequired) - } + // MFA Hints Selection (if multiple available) + if let mfaRequired = mfaRequired, mfaRequired.hints.count > 1 { + mfaHintsSelectionView(mfaRequired: mfaRequired) + } - // Resolution Content - if let hint = selectedHint { - resolutionContent(for: hint) - } + // Resolution Content + if let hint = selectedHint { + resolutionContent(for: hint) + } - // Error message - if !errorMessage.isEmpty { - Text(errorMessage) - .foregroundColor(.red) - .font(.caption) - .padding(.horizontal) - .accessibilityIdentifier("error-message") + // Action buttons + VStack(spacing: 12) { + // Complete Resolution Button + Button(action: completeResolution) { + HStack { + if isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Complete Sign-In") + } + .frame(maxWidth: .infinity) + .padding() + .background(canCompleteResolution ? Color.blue : Color.gray) + .foregroundColor(.white) + .cornerRadius(8) } + .disabled(!canCompleteResolution) + .accessibilityIdentifier("complete-resolution-button") - // Action buttons - VStack(spacing: 12) { - // Complete Resolution Button - Button(action: completeResolution) { - HStack { - if isLoading { - ProgressView() - .scaleEffect(0.8) - } - Text("Complete Sign-In") - } + // Cancel Button + Button(action: cancelResolution) { + Text("Cancel") .frame(maxWidth: .infinity) .padding() - .background(canCompleteResolution ? Color.blue : Color.gray) - .foregroundColor(.white) + .background(Color.gray.opacity(0.2)) + .foregroundColor(.primary) .cornerRadius(8) - } - .disabled(!canCompleteResolution) - .accessibilityIdentifier("complete-resolution-button") - - // Cancel Button - Button(action: cancelResolution) { - Text("Cancel") - .frame(maxWidth: .infinity) - .padding() - .background(Color.gray.opacity(0.2)) - .foregroundColor(.primary) - .cornerRadius(8) - } - .accessibilityIdentifier("cancel-button") } - .padding(.horizontal) + .accessibilityIdentifier("cancel-button") + } + .padding(.horizontal) } .padding(.vertical, 20) } @@ -380,7 +366,7 @@ private extension MFAHint { FirebaseOptions.dummyConfigurationForPreview() let authService = AuthService() authService.currentMFARequired = MFARequired(hints: [ - .phone(displayName: "Work Phone", uid: "phone-uid-1", phoneNumber: "+15551234567") + .phone(displayName: "Work Phone", uid: "phone-uid-1", phoneNumber: "+15551234567"), ]) return MFAResolutionView().environment(authService) } @@ -389,7 +375,7 @@ private extension MFAHint { FirebaseOptions.dummyConfigurationForPreview() let authService = AuthService() authService.currentMFARequired = MFARequired(hints: [ - .totp(displayName: "Authenticator App", uid: "totp-uid-1") + .totp(displayName: "Authenticator App", uid: "totp-uid-1"), ]) return MFAResolutionView().environment(authService) } @@ -399,7 +385,7 @@ private extension MFAHint { let authService = AuthService() authService.currentMFARequired = MFARequired(hints: [ .phone(displayName: "Mobile", uid: "phone-uid-1", phoneNumber: "+15551234567"), - .totp(displayName: "Google Authenticator", uid: "totp-uid-1") + .totp(displayName: "Google Authenticator", uid: "totp-uid-1"), ]) return MFAResolutionView().environment(authService) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift index d9fe8c99a7..b258e464f5 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift @@ -15,26 +15,21 @@ import FirebaseCore import SwiftUI -private struct ResultWrapper: Identifiable { - let id = UUID() - let value: Result -} - public struct PasswordRecoveryView { @Environment(AuthService.self) private var authService @State private var email = "" - @State private var resultWrapper: ResultWrapper? + @State private var showSuccessSheet = false + @State private var sentEmail = "" public init() {} private func sendPasswordRecoveryEmail() async { - let recoveryResult: Result - do { try await authService.sendPasswordRecoveryEmail(email: email) - resultWrapper = ResultWrapper(value: .success(())) + sentEmail = email + showSuccessSheet = true } catch { - resultWrapper = ResultWrapper(value: .failure(error)) + // Error already displayed via modal by AuthService } } } @@ -94,45 +89,33 @@ extension PasswordRecoveryView: View { .frame(maxWidth: .infinity) .buttonStyle(.borderedProminent) } - .sheet(item: $resultWrapper) { wrapper in - resultSheet(wrapper.value) + .sheet(isPresented: $showSuccessSheet) { + successSheet } } @ViewBuilder @MainActor - private func resultSheet(_ result: Result) -> some View { + private var successSheet: some View { VStack { - switch result { - case .success: - Text(authService.string.passwordRecoveryEmailSentTitle) - .font(.largeTitle) - .fontWeight(.bold) - .padding() - Text(authService.string.passwordRecoveryHelperMessage) - .padding() - - Divider() - - Text(String(format: authService.string.passwordRecoveryEmailSentMessage, email)) - .padding() - - case .failure: - Text(authService.string.alertErrorTitle) - .font(.title) - .fontWeight(.semibold) - .padding() + Text(authService.string.passwordRecoveryEmailSentTitle) + .font(.largeTitle) + .fontWeight(.bold) + .padding() + Text(authService.string.passwordRecoveryHelperMessage) + .padding() - Divider() + Divider() - Text(authService.errorMessage) - .padding() - } + Text(String(format: authService.string.passwordRecoveryEmailSentMessage, sentEmail)) + .padding() Divider() Button(authService.string.okButtonLabel) { - self.resultWrapper = nil + showSuccessSheet = false + email = "" + authService.authView = .authPicker } .padding() } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift index 9be336619a..68f4f90de7 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift @@ -39,7 +39,9 @@ extension SignedInView: View { .padding() .accessibilityIdentifier("signed-in-text") Text("as:") - Text("\(authService.currentUser?.email ?? authService.currentUser?.displayName ?? "Unknown")") + Text( + "\(authService.currentUser?.email ?? authService.currentUser?.displayName ?? "Unknown")" + ) if authService.currentUser?.isEmailVerified == false { VerifyEmailView() } @@ -55,21 +57,17 @@ extension SignedInView: View { Divider() Button(authService.string.signOutButtonLabel) { Task { - do { - try await authService.signOut() - } catch {} + try? await authService.signOut() } }.accessibilityIdentifier("sign-out-button") Divider() Button(authService.string.deleteAccountButtonLabel) { Task { - do { - try await authService.deleteUser() - } catch {} + try? await authService.deleteUser() } } - Text(authService.errorMessage).foregroundColor(.red) - }.sheet(isPresented: isShowingPasswordPrompt) { + } + .sheet(isPresented: isShowingPasswordPrompt) { PasswordPromptSheet(coordinator: authService.passwordPrompt) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/VerifyEmailView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/VerifyEmailView.swift index ac2c2f8be5..724d1480f3 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/VerifyEmailView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/VerifyEmailView.swift @@ -23,7 +23,9 @@ public struct VerifyEmailView { do { try await authService.sendEmailVerification() showModal = true - } catch {} + } catch { + // Error already displayed via modal by AuthService + } } } diff --git a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift index e2f79f18f2..96d7763422 100644 --- a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift +++ b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift @@ -24,7 +24,6 @@ import SwiftUI public struct SignInWithFacebookButton { @Environment(AuthService.self) private var authService let facebookProvider: AuthProviderSwift - @State private var errorMessage = "" @State private var showCanceledAlert = false @State private var limitedLogin = true @State private var showUserTrackingAlert = false @@ -64,65 +63,68 @@ public struct SignInWithFacebookButton { extension SignInWithFacebookButton: View { public var body: some View { - Button(action: { - Task { - do { - try await authService.signIn(facebookProvider) - } catch { - switch error { - case FacebookProviderError.signInCancelled: - showCanceledAlert = true - default: - errorMessage = authService.string.localizedErrorMessage(for: error) + VStack { + Button(action: { + Task { + do { + try await authService.signIn(facebookProvider) + } catch { + switch error { + case FacebookProviderError.signInCancelled: + showCanceledAlert = true + default: + // Error already handled by AuthService + break + } } } + }) { + HStack { + Image(systemName: "f.circle.fill") + .font(.title2) + .foregroundColor(.white) + Text(authService.string.facebookLoginButtonLabel) + .fontWeight(.semibold) + .foregroundColor(.white) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .frame(maxWidth: .infinity) + .background(Color.blue) + .cornerRadius(8) } - }) { + .accessibilityIdentifier("sign-in-with-facebook-button") + HStack { - Image(systemName: "f.circle.fill") - .font(.title2) - .foregroundColor(.white) - Text(authService.string.facebookLoginButtonLabel) - .fontWeight(.semibold) - .foregroundColor(.white) + Text(authService.string.authorizeUserTrackingLabel) + .font(.footnote) + .foregroundColor(.blue) + .underline() + .onTapGesture { + requestTrackingPermission() + } + Toggle(isOn: limitedLoginBinding) { + HStack { + Spacer() // This will push the text to the left of the toggle + Text(authService.string.facebookLimitedLoginLabel) + .foregroundColor(.blue) + } + } + .toggleStyle(SwitchToggleStyle(tint: .green)) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .frame(maxWidth: .infinity) - .background(Color.blue) - .cornerRadius(8) } - .accessibilityIdentifier("sign-in-with-facebook-button") .alert(isPresented: $showCanceledAlert) { Alert( title: Text(authService.string.facebookLoginCancelledLabel), dismissButton: .default(Text(authService.string.okButtonLabel)) ) } - - HStack { - Text(authService.string.authorizeUserTrackingLabel) - .font(.footnote) - .foregroundColor(.blue) - .underline() - .onTapGesture { - requestTrackingPermission() - } - Toggle(isOn: limitedLoginBinding) { - HStack { - Spacer() // This will push the text to the left of the toggle - Text(authService.string.facebookLimitedLoginLabel) - .foregroundColor(.blue) - } - } - .toggleStyle(SwitchToggleStyle(tint: .green)) - .alert(isPresented: $showUserTrackingAlert) { - Alert( - title: Text(authService.string.authorizeUserTrackingLabel), - message: Text(authService.string.facebookAuthorizeUserTrackingMessage), - dismissButton: .default(Text(authService.string.okButtonLabel)) - ) - } + .alert(isPresented: $showUserTrackingAlert) { + Alert( + title: Text(authService.string.authorizeUserTrackingLabel), + message: Text(authService.string.facebookAuthorizeUserTrackingMessage), + dismissButton: .default(Text(authService.string.okButtonLabel)) + ) } } } diff --git a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift index 3a61a6f70d..881b0ffbad 100644 --- a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift +++ b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift @@ -43,7 +43,7 @@ extension SignInWithGoogleButton: View { public var body: some View { GoogleSignInButton(viewModel: customViewModel) { Task { - try await authService.signIn(googleProvider) + try? await authService.signIn(googleProvider) } } .accessibilityIdentifier("sign-in-with-google-button") diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift.swift b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift.swift index 9e952b79f1..1f71aba5ef 100644 --- a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift.swift +++ b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift.swift @@ -22,13 +22,11 @@ public class OAuthProviderSwift: AuthProviderSwift, DeleteUserSwift { public let providerId: String public let scopes: [String] public let customParameters: [String: String] - // Button appearance public let displayName: String public let buttonIcon: Image public let buttonBackgroundColor: Color public let buttonForegroundColor: Color - /// Initialize a generic OAuth provider /// - Parameters: /// - providerId: The OAuth provider ID (e.g., "github.com", "microsoft.com") @@ -53,7 +51,6 @@ public class OAuthProviderSwift: AuthProviderSwift, DeleteUserSwift { self.buttonBackgroundColor = buttonBackgroundColor self.buttonForegroundColor = buttonForegroundColor } - /// Convenience initializer using SF Symbol /// - Parameters: /// - providerId: The OAuth provider ID (e.g., "github.com", "microsoft.com") @@ -88,7 +85,6 @@ public class OAuthProviderSwift: AuthProviderSwift, DeleteUserSwift { if !scopes.isEmpty { provider.scopes = scopes } - // Set custom parameters if provided if !customParameters.isEmpty { provider.customParameters = customParameters @@ -136,7 +132,6 @@ public class OAuthProviderAuthUI: AuthProviderUI { } return oauthProvider.providerId } - @MainActor public func authButton() -> AnyView { AnyView(GenericOAuthButton(provider: provider)) } diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift index 0884dc8c8d..a14082b328 100644 --- a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift +++ b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift @@ -20,7 +20,6 @@ import SwiftUI public struct GenericOAuthButton { @Environment(AuthService.self) private var authService let provider: AuthProviderSwift - public init(provider: AuthProviderSwift) { self.provider = provider } @@ -34,7 +33,6 @@ extension GenericOAuthButton: View { .foregroundColor(.red) ) } - return AnyView( Button(action: { Task { @@ -48,7 +46,6 @@ extension GenericOAuthButton: View { .scaledToFit() .frame(width: 24, height: 24) .foregroundColor(oauthProvider.buttonForegroundColor) - Text(oauthProvider.displayName) .fontWeight(.semibold) .foregroundColor(oauthProvider.buttonForegroundColor) diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthView.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthView.swift index 0ec37e0b93..7d76df72c4 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthView.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthView.swift @@ -27,7 +27,7 @@ import SwiftUI @MainActor public struct PhoneAuthView { @Environment(\.dismiss) private var dismiss - @State private var errorMessage = "" + @State private var currentError: AlertError? @State private var phoneNumber = "" @State private var showVerificationCodeInput = false @State private var verificationCode = "" @@ -85,13 +85,6 @@ extension PhoneAuthView: View { .padding(.bottom, 4) .padding(.horizontal) - if !errorMessage.isEmpty { - Text(errorMessage) - .foregroundColor(.red) - .font(.caption) - .padding(.horizontal) - } - Button(action: { Task { isProcessing = true @@ -99,9 +92,9 @@ extension PhoneAuthView: View { let id = try await phoneProvider.verifyPhoneNumber(phoneNumber: phoneNumber) verificationID = id showVerificationCodeInput = true - errorMessage = "" + currentError = nil } catch { - errorMessage = error.localizedDescription + currentError = AlertError(message: error.localizedDescription) } isProcessing = false } @@ -153,13 +146,6 @@ extension PhoneAuthView: View { .cornerRadius(8) .padding(.horizontal) - if !errorMessage.isEmpty { - Text(errorMessage) - .foregroundColor(.red) - .font(.caption) - .padding(.horizontal) - } - Button(action: { Task { isProcessing = true @@ -184,6 +170,7 @@ extension PhoneAuthView: View { .padding(.vertical) } } + .errorAlert(error: $currentError, okButtonLabel: "OK") } } diff --git a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift index e9222fad55..d85e9052e1 100644 --- a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift +++ b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift @@ -29,7 +29,7 @@ extension SignInWithTwitterButton: View { public var body: some View { Button(action: { Task { - try await authService.signIn(provider) + try? await authService.signIn(provider) } }) { HStack { diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift index 8f08a8e7e4..4bf18e9345 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift @@ -102,7 +102,7 @@ struct FirebaseSwiftUIExampleTests { #expect(service.authenticationState == .unauthenticated) #expect(service.authView == .authPicker) - #expect(service.errorMessage.isEmpty) + #expect(service.currentError == nil) #expect(service.signedInCredential == nil) #expect(service.currentUser == nil) try await service.createUser(email: createEmail(), password: kPassword) @@ -117,7 +117,7 @@ struct FirebaseSwiftUIExampleTests { } #expect(service.currentUser != nil) #expect(service.authView == .authPicker) - #expect(service.errorMessage.isEmpty) + #expect(service.currentError == nil) } @Test @@ -138,7 +138,7 @@ struct FirebaseSwiftUIExampleTests { } #expect(service.currentUser == nil) #expect(service.authView == .authPicker) - #expect(service.errorMessage.isEmpty) + #expect(service.currentError == nil) #expect(service.signedInCredential == nil) try await service.signIn(email: email, password: kPassword) @@ -157,6 +157,6 @@ struct FirebaseSwiftUIExampleTests { } #expect(service.signedInCredential != nil) #expect(service.authView == .authPicker) - #expect(service.errorMessage.isEmpty) + #expect(service.currentError == nil) } } diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift index 7d010d8777..b134e5fb34 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift @@ -43,68 +43,6 @@ final class FirebaseSwiftUIExampleUITests: XCTestCase { } } - @MainActor - func testSignInButtonsExist() throws { - let app = createTestApp() - app.launch() - - // Check for Twitter/X sign-in button - let twitterButton = app.buttons["sign-in-with-twitter-button"] - XCTAssertTrue( - twitterButton.waitForExistence(timeout: 5), - "Twitter/X sign-in button should exist" - ) - - // Check for Apple sign-in button - let appleButton = app.buttons["sign-in-with-apple-button"] - XCTAssertTrue( - appleButton.waitForExistence(timeout: 5), - "Apple sign-in button should exist" - ) - - // Check for Github sign-in button - let githubButton = app.buttons["sign-in-with-github.com-button"] - XCTAssertTrue( - githubButton.waitForExistence(timeout: 5), - "Github sign-in button should exist" - ) - - // Check for Microsoft sign-in button - let microsoftButton = app.buttons["sign-in-with-microsoft.com-button"] - XCTAssertTrue( - microsoftButton.waitForExistence(timeout: 5), - "Microsoft sign-in button should exist" - ) - - // Check for Yahoo sign-in button - let yahooButton = app.buttons["sign-in-with-yahoo.com-button"] - XCTAssertTrue( - yahooButton.waitForExistence(timeout: 5), - "Yahoo sign-in button should exist" - ) - - // Check for Google sign-in button - let googleButton = app.buttons["sign-in-with-google-button"] - XCTAssertTrue( - googleButton.waitForExistence(timeout: 5), - "Google sign-in button should exist" - ) - - // Check for Facebook sign-in button - let facebookButton = app.buttons["sign-in-with-facebook-button"] - XCTAssertTrue( - facebookButton.waitForExistence(timeout: 5), - "Facebook sign-in button should exist" - ) - - // Check for Phone sign-in button - let phoneButton = app.buttons["sign-in-with-phone-button"] - XCTAssertTrue( - phoneButton.waitForExistence(timeout: 5), - "Phone sign-in button should exist" - ) - } - @MainActor func testSignInDisplaysSignedInView() async throws { let email = createEmail() diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift index 924d6629c3..f1d5c93a77 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift @@ -335,15 +335,6 @@ final class MFAEnrollmentUITests: XCTestCase { // Start enrollment to trigger potential errors app.buttons["start-enrollment-button"].tap() - - // Check if error message element exists (it might not be visible initially) - let errorMessage = app.staticTexts["error-message"] - - // The error message element should exist even if not currently displaying an error - // In real scenarios, this would test actual error conditions - if errorMessage.exists { - XCTAssertTrue(true, "Error message element exists for error display") - } } // MARK: - Navigation Tests diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/ProviderUITests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/ProviderUITests.swift new file mode 100644 index 0000000000..8ae3b6ace3 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/ProviderUITests.swift @@ -0,0 +1,151 @@ +// 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. + +// +// FirebaseSwiftUIExampleUITests.swift +// FirebaseSwiftUIExampleUITests +// +// Created by Russell Wheatley on 18/02/2025. +// + +import XCTest + +final class ProviderUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws {} + + @MainActor + func testProviderButtons() throws { + let app = createTestApp() + app.launch() + + // MARK: - Check existence of provider buttons + + // Check for Twitter/X sign-in button + let twitterButton = app.buttons["sign-in-with-twitter-button"] + XCTAssertTrue( + twitterButton.waitForExistence(timeout: 5), + "Twitter/X sign-in button should exist" + ) + + // Check for Apple sign-in button + let appleButton = app.buttons["sign-in-with-apple-button"] + XCTAssertTrue( + appleButton.waitForExistence(timeout: 5), + "Apple sign-in button should exist" + ) + + // Check for Github sign-in button + let githubButton = app.buttons["sign-in-with-github.com-button"] + XCTAssertTrue( + githubButton.waitForExistence(timeout: 5), + "Github sign-in button should exist" + ) + + // Check for Microsoft sign-in button + let microsoftButton = app.buttons["sign-in-with-microsoft.com-button"] + XCTAssertTrue( + microsoftButton.waitForExistence(timeout: 5), + "Microsoft sign-in button should exist" + ) + + // Check for Yahoo sign-in button + let yahooButton = app.buttons["sign-in-with-yahoo.com-button"] + XCTAssertTrue( + yahooButton.waitForExistence(timeout: 5), + "Yahoo sign-in button should exist" + ) + + // Check for Google sign-in button + let googleButton = app.buttons["sign-in-with-google-button"] + XCTAssertTrue( + googleButton.waitForExistence(timeout: 5), + "Google sign-in button should exist" + ) + + // Check for Facebook sign-in button + let facebookButton = app.buttons["sign-in-with-facebook-button"] + XCTAssertTrue( + facebookButton.waitForExistence(timeout: 5), + "Facebook sign-in button should exist" + ) + + // Check for Phone sign-in button + let phoneButton = app.buttons["sign-in-with-phone-button"] + XCTAssertTrue( + phoneButton.waitForExistence(timeout: 5), + "Phone sign-in button should exist" + ) + } + + @MainActor + func testErrorModal() throws { + let app = createTestApp() + app.launch() + // Just test email + external provider for error modal on failure to ensure provider button sign-in flow fails along with failures within AuthPickerView + let emailField = app.textFields["email-field"] + XCTAssertTrue(emailField.waitForExistence(timeout: 6), "Email field should exist") + emailField.tap() + emailField.typeText("fake-email@example.com") + + let passwordField = app.secureTextFields["password-field"] + XCTAssertTrue(passwordField.exists, "Password field should exist") + passwordField.tap() + passwordField.typeText("12345678") + + let signInButton = app.buttons["sign-in-button"] + XCTAssertTrue(signInButton.exists, "Sign-In button should exist") + signInButton.tap() + + // Wait for the alert to appear + let alert1 = app.alerts.firstMatch + XCTAssertTrue( + alert1.waitForExistence(timeout: 5), + "Alert should appear after canceling Facebook sign-in" + ) + + alert1.buttons["OK"].firstMatch.tap() + + + let facebookButton = app.buttons["sign-in-with-facebook-button"] + XCTAssertTrue( + facebookButton.waitForExistence(timeout: 5), + "Facebook sign-in button should exist" + ) + + facebookButton.tap() + + // Wait for Facebook modal to appear and tap Cancel + // The Facebook SDK modal is presented by the system/Safari + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + + // Access the Cancel button from Springboard + let cancelButton = springboard.buttons["Cancel"] + XCTAssertTrue( + cancelButton.waitForExistence(timeout: 10), + "Cancel button should appear in Springboard authentication modal" + ) + cancelButton.tap() + + // Wait for the alert to appear + let alert2 = app.alerts.firstMatch + XCTAssertTrue( + alert2.waitForExistence(timeout: 5), + "Alert should appear after canceling Facebook sign-in" + ) + } +}