diff --git a/.github/workflows/swiftui-auth.yml b/.github/workflows/swiftui-auth.yml index 40ebc5a023..44f17a59c5 100644 --- a/.github/workflows/swiftui-auth.yml +++ b/.github/workflows/swiftui-auth.yml @@ -2,14 +2,14 @@ name: SwiftUI Auth on: push: - branches: [ main ] + branches: [ main, development ] paths: - '.github/workflows/swiftui-auth.yml' - 'samples/swiftui/**' - 'FirebaseSwiftUI/**' - 'Package.swift' pull_request: - branches: [ main ] + branches: [ main, development ] paths: - '.github/workflows/swiftui-auth.yml' - 'samples/swiftui/**' @@ -22,57 +22,173 @@ permissions: contents: read jobs: - swiftui-auth: - runs-on: macos-15 - timeout-minutes: 30 + # Package Unit Tests (standalone, no emulator needed) + unit-tests: + name: Package Unit Tests + runs-on: macos-26 + timeout-minutes: 15 steps: - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 + + - name: Install xcpretty + run: gem install xcpretty + + - name: Select Xcode version + run: sudo xcode-select -switch /Applications/Xcode_26.0.1.app/Contents/Developer + + - name: Run FirebaseSwiftUI Package Unit Tests + run: | + set -o pipefail + xcodebuild test \ + -scheme FirebaseUI-Package \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -enableCodeCoverage YES \ + -resultBundlePath FirebaseSwiftUIPackageTests.xcresult | tee FirebaseSwiftUIPackageTests.log | xcpretty --test --color --simple + + - name: Upload test logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: unit-tests-logs + path: FirebaseSwiftUIPackageTests.log + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: unit-tests-results + path: FirebaseSwiftUIPackageTests.xcresult + + # Integration Tests (requires emulator) + integration-tests: + name: Integration Tests + runs-on: macos-26 + timeout-minutes: 20 + steps: + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 + - uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a name: Install Node.js 20 with: node-version: '20' + - uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b with: distribution: 'temurin' java-version: '17' + - name: Install Firebase - run: | - sudo npm i -g firebase-tools + run: sudo npm i -g firebase-tools + - name: Start Firebase Emulator run: | - sudo chown -R 501:20 "/Users/runner/.npm" && cd ./samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample && ./start-firebase-emulator.sh + sudo chown -R 501:20 "/Users/runner/.npm" + cd ./samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample + ./start-firebase-emulator.sh + - name: Install xcpretty run: gem install xcpretty + - name: Select Xcode version - run: | - sudo xcode-select -switch /Applications/Xcode_16.3.app/Contents/Developer - - name: Run Integration Tests + run: sudo xcode-select -switch /Applications/Xcode_26.0.1.app/Contents/Developer + + - name: Build for Integration Tests run: | cd ./samples/swiftui/FirebaseSwiftUIExample set -o pipefail - xcodebuild test -scheme FirebaseSwiftUIExampleTests -destination 'platform=iOS Simulator,name=iPhone 16 Pro' -enableCodeCoverage YES -resultBundlePath FirebaseSwiftUIExampleTests.xcresult | tee FirebaseSwiftUIExampleTests.log | xcpretty --test --color --simple - - name: Run View UI Tests + xcodebuild build-for-testing \ + -scheme FirebaseSwiftUIExampleTests \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -enableCodeCoverage YES | xcpretty --color --simple + + - name: Run Integration Tests run: | cd ./samples/swiftui/FirebaseSwiftUIExample set -o pipefail - xcodebuild test -scheme FirebaseSwiftUIExampleUITests -destination 'platform=iOS Simulator,name=iPhone 16 Pro' -enableCodeCoverage YES -resultBundlePath FirebaseSwiftUIExampleUITests.xcresult | tee FirebaseSwiftUIExampleUITests.log | xcpretty --test --color --simple + xcodebuild test-without-building \ + -scheme FirebaseSwiftUIExampleTests \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -enableCodeCoverage YES \ + -resultBundlePath FirebaseSwiftUIExampleTests.xcresult | tee FirebaseSwiftUIExampleTests.log | xcpretty --test --color --simple + - name: Upload test logs if: failure() uses: actions/upload-artifact@v4 with: - name: swiftui-auth-test-logs - path: | - samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests.log - samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests.log - - name: Upload FirebaseSwiftUIExampleUITests.xcresult bundle + name: integration-tests-logs + path: samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests.log + + - name: Upload test results if: failure() uses: actions/upload-artifact@v4 with: - name: FirebaseSwiftUIExampleUITests.xcresult - path: samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests.xcresult - - name: Upload FirebaseSwiftUIExampleTests.xcresult bundle + name: integration-tests-results + path: samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests.xcresult + + # UI Tests (requires emulator) + ui-tests: + name: UI Tests + runs-on: macos-26 + timeout-minutes: 40 + steps: + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 + + - uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a + name: Install Node.js 20 + with: + node-version: '20' + + - uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b + with: + distribution: 'temurin' + java-version: '17' + + - name: Install Firebase + run: sudo npm i -g firebase-tools + + - name: Start Firebase Emulator + run: | + sudo chown -R 501:20 "/Users/runner/.npm" + cd ./samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample + ./start-firebase-emulator.sh + + - name: Install xcpretty + run: gem install xcpretty + + - name: Select Xcode version + run: sudo xcode-select -switch /Applications/Xcode_26.0.1.app/Contents/Developer + + - name: Build for UI Tests + run: | + cd ./samples/swiftui/FirebaseSwiftUIExample + set -o pipefail + xcodebuild build-for-testing \ + -scheme FirebaseSwiftUIExampleUITests \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -enableCodeCoverage YES | xcpretty --color --simple + + - name: Run UI Tests + run: | + cd ./samples/swiftui/FirebaseSwiftUIExample + set -o pipefail + xcodebuild test-without-building \ + -scheme FirebaseSwiftUIExampleUITests \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -parallel-testing-enabled YES \ + -maximum-concurrent-test-simulator-destinations 2 \ + -enableCodeCoverage YES \ + -resultBundlePath FirebaseSwiftUIExampleUITests.xcresult | tee FirebaseSwiftUIExampleUITests.log | xcpretty --test --color --simple + + - name: Upload Firebase Emulator logs if: failure() uses: actions/upload-artifact@v4 with: - name: FirebaseSwiftUIExampleTests.xcresult - path: samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests.xcresult \ No newline at end of file + name: firebase-emulator-logs + path: samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/firebase-debug.log + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: FirebaseSwiftUIExampleUITests.xcresult + path: samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests.xcresult \ No newline at end of file diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift new file mode 100644 index 0000000000..14bc54cc82 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AppleProviderAuthUI.swift @@ -0,0 +1,154 @@ +// 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 AuthenticationServices +import CryptoKit +import FirebaseAuth +import FirebaseAuthSwiftUI +import FirebaseCore +import SwiftUI + +// MARK: - Data Extensions + +extension Data { + var utf8String: String? { + return String(data: self, encoding: .utf8) + } +} + +extension ASAuthorizationAppleIDCredential { + var authorizationCodeString: String? { + return authorizationCode?.utf8String + } + + var idTokenString: String? { + return identityToken?.utf8String + } +} + +// MARK: - Authenticate With Apple Dialog + +private func authenticateWithApple(scopes: [ASAuthorization.Scope]) async throws -> ( + ASAuthorizationAppleIDCredential, + String +) { + return try await AuthenticateWithAppleDialog(scopes: scopes).authenticate() +} + +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() + } + + func authenticate() async throws -> (ASAuthorizationAppleIDCredential, String) { + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + + let appleIDProvider = ASAuthorizationAppleIDProvider() + let request = appleIDProvider.createRequest() + request.requestedScopes = scopes + + do { + let nonce = try CryptoUtils.randomNonceString() + currentNonce = nonce + request.nonce = CryptoUtils.sha256(nonce) + } catch { + continuation.resume(throwing: AuthServiceError.signInFailed(underlying: error)) + return + } + + let authorizationController = ASAuthorizationController(authorizationRequests: [request]) + authorizationController.delegate = self + authorizationController.performRequests() + } + } +} + +extension AuthenticateWithAppleDialog: ASAuthorizationControllerDelegate { + func authorizationController(controller _: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization) { + if let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential { + if let nonce = currentNonce { + continuation?.resume(returning: (appleIDCredential, nonce)) + } else { + continuation?.resume( + throwing: AuthServiceError.signInFailed( + underlying: NSError( + domain: "AppleSignIn", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Missing nonce"] + ) + ) + ) + } + } else { + continuation?.resume( + throwing: AuthServiceError.invalidCredentials("Missing Apple ID credential") + ) + } + continuation = nil + } + + func authorizationController(controller _: ASAuthorizationController, + didCompleteWithError error: Error) { + continuation?.resume(throwing: AuthServiceError.signInFailed(underlying: error)) + continuation = nil + } +} + +// MARK: - Apple Provider Swift + +public class AppleProviderSwift: AuthProviderSwift { + public let scopes: [ASAuthorization.Scope] + let providerId = "apple.com" + + public init(scopes: [ASAuthorization.Scope] = [.fullName, .email]) { + self.scopes = scopes + } + + @MainActor public func createAuthCredential() async throws -> AuthCredential { + let (appleIDCredential, nonce) = try await authenticateWithApple(scopes: scopes) + + guard let idTokenString = appleIDCredential.idTokenString else { + throw AuthServiceError.invalidCredentials("Unable to fetch identity token from Apple") + } + + let credential = OAuthProvider.appleCredential( + withIDToken: idTokenString, + rawNonce: nonce, + fullName: appleIDCredential.fullName + ) + + return credential + } +} + +public class AppleProviderAuthUI: AuthProviderUI { + public var provider: AuthProviderSwift + + public init(provider: AuthProviderSwift) { + self.provider = provider + } + + public let id: String = "apple.com" + + @MainActor public func authButton() -> AnyView { + AnyView(SignInWithAppleButton(provider: provider)) + } +} diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AuthService+Apple.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AuthService+Apple.swift new file mode 100644 index 0000000000..43ee3773a0 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/AuthService+Apple.swift @@ -0,0 +1,31 @@ +// 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. + +// +// AuthService+Apple.swift +// FirebaseUI +// +// Created by Russell Wheatley on 21/10/2025. +// + +import FirebaseAuthSwiftUI + +public extension AuthService { + @discardableResult + func withAppleSignIn(_ provider: AppleProviderSwift? = nil) -> AuthService { + registerProvider(providerWithButton: AppleProviderAuthUI(provider: provider ?? + AppleProviderSwift())) + return self + } +} diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/CryptoUtils.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/CryptoUtils.swift new file mode 100644 index 0000000000..b96bac7887 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Services/CryptoUtils.swift @@ -0,0 +1,52 @@ +// 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 CryptoKit +import Foundation + +/// Set of utility APIs for generating cryptographical artifacts. +enum CryptoUtils { + enum NonceGenerationError: Error { + case generationFailure(status: OSStatus) + } + + static func randomNonceString(length: Int = 32) throws -> String { + precondition(length > 0) + var randomBytes = [UInt8](repeating: 0, count: length) + let errorCode = SecRandomCopyBytes(kSecRandomDefault, randomBytes.count, &randomBytes) + if errorCode != errSecSuccess { + throw NonceGenerationError.generationFailure(status: errorCode) + } + + let charset: [Character] = + Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._") + + let nonce = randomBytes.map { byte in + // Pick a random character from the set, wrapping around if needed. + charset[Int(byte) % charset.count] + } + + return String(nonce) + } + + static func sha256(_ input: String) -> String { + let inputData = Data(input.utf8) + let hashedData = SHA256.hash(data: inputData) + let hashString = hashedData.compactMap { + String(format: "%02x", $0) + }.joined() + + return hashString + } +} diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift new file mode 100644 index 0000000000..511582d2e8 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift @@ -0,0 +1,48 @@ +// 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 FirebaseAuthSwiftUI +import FirebaseAuthUIComponents +import SwiftUI + +/// A button for signing in with Apple +@MainActor +public struct SignInWithAppleButton { + @Environment(AuthService.self) private var authService + @Environment(\.signInWithMergeConflictHandler) private var signInHandler + let provider: AuthProviderSwift + public init(provider: AuthProviderSwift) { + self.provider = provider + } +} + +extension SignInWithAppleButton: View { + public var body: some View { + AuthProviderButton( + label: "Sign in with Apple", + style: .apple, + accessibilityId: "sign-in-with-apple-button" + ) { + Task { + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(provider) + } + } else { + try? await authService.signIn(provider) + } + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAppleSwiftUI/Tests/FirebaseAppleSwiftUITests/FirebaseAppleSwiftUITests.swift b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Tests/FirebaseAppleSwiftUITests/FirebaseAppleSwiftUITests.swift new file mode 100644 index 0000000000..abbc709cd6 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Tests/FirebaseAppleSwiftUITests/FirebaseAppleSwiftUITests.swift @@ -0,0 +1,20 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@testable import FirebaseAppleSwiftUI +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 new file mode 100644 index 0000000000..e2be260f56 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Auth/MultiFactor.swift @@ -0,0 +1,114 @@ +// 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. +@preconcurrency import FirebaseAuth +import SwiftUI + +public enum SecondFactorType { + case sms + case totp +} + +public struct TOTPEnrollmentInfo { + public let sharedSecretKey: String + public let qrCodeURL: URL? + public let accountName: String? + public let issuer: String? + public let verificationStatus: VerificationStatus + + public enum VerificationStatus { + case pending + case verified + case failed + } + + public init(sharedSecretKey: String, + qrCodeURL: URL? = nil, + accountName: String? = nil, + issuer: String? = nil, + verificationStatus: VerificationStatus = .pending) { + self.sharedSecretKey = sharedSecretKey + self.qrCodeURL = qrCodeURL + self.accountName = accountName + self.issuer = issuer + self.verificationStatus = verificationStatus + } +} + +public struct EnrollmentSession { + public let id: String + public let type: SecondFactorType + public let session: MultiFactorSession + public let totpInfo: TOTPEnrollmentInfo? + public let phoneNumber: String? + public let verificationId: String? + public let status: EnrollmentStatus + public let createdAt: Date + public let expiresAt: Date + + // Internal handle to finish TOTP + let _totpSecret: AnyObject? + + public enum EnrollmentStatus { + case initiated + case verificationSent + case verificationPending + case completed + case failed + case expired + } + + public init(id: String = UUID().uuidString, + type: SecondFactorType, + session: MultiFactorSession, + totpInfo: TOTPEnrollmentInfo? = nil, + phoneNumber: String? = nil, + verificationId: String? = nil, + status: EnrollmentStatus = .initiated, + createdAt: Date = Date(), + expiresAt: Date = Date().addingTimeInterval(600), // 10 minutes default + _totpSecret: AnyObject? = nil) { + self.id = id + self.type = type + self.session = session + self.totpInfo = totpInfo + self.phoneNumber = phoneNumber + self.verificationId = verificationId + self.status = status + self.createdAt = createdAt + self.expiresAt = expiresAt + self._totpSecret = _totpSecret + } + + public var isExpired: Bool { + return Date() > expiresAt + } + + public var canProceed: Bool { + return !isExpired && + (status == .initiated || status == .verificationSent || status == .verificationPending) + } +} + +public enum MFAHint { + case phone(displayName: String?, uid: String, phoneNumber: String?) + case totp(displayName: String?, uid: String) +} + +public struct MFARequired { + public let hints: [MFAHint] + + public init(hints: [MFAHint]) { + self.hints = hints + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift index badbc98519..0a05bde4fa 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift @@ -36,6 +36,11 @@ public enum AuthServiceError: LocalizedError { case invalidCredentials(String) case signInFailed(underlying: Error) case accountMergeConflict(context: AccountMergeConflictContext) + case providerNotFound(String) + case multiFactorAuth(String) + case rootViewControllerNotFound(String) + case providerAuthenticationFailed(String) + case signInCancelled(String) public var errorDescription: String? { switch self { @@ -51,10 +56,22 @@ public enum AuthServiceError: LocalizedError { return description case let .invalidCredentials(description): return description + // Use when failed to sign-in with Firebase case let .signInFailed(underlying: error): return "Failed to sign in: \(error.localizedDescription)" + // Use when failed to sign-in with provider (e.g. Google, Facebook, etc.) + case let .providerAuthenticationFailed(description): + return description + case let .signInCancelled(description): + return description case let .accountMergeConflict(context): return context.errorDescription + case let .providerNotFound(description): + return description + case let .multiFactorAuth(description): + return description + case let .rootViewControllerNotFound(description): + return description } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AccountService+Email.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AccountService+Email.swift index 59bcb0d594..6f77ef6d67 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AccountService+Email.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AccountService+Email.swift @@ -12,70 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -@preconcurrency import FirebaseAuth import Observation -protocol EmailPasswordOperationReauthentication { - var passwordPrompt: PasswordPromptCoordinator { get } -} - -extension EmailPasswordOperationReauthentication { - // TODO: - @MainActor because User is non-sendable. Might change this once User is sendable in firebase-ios-sdk - @MainActor func reauthenticate() async throws -> AuthenticationToken { - guard let user = Auth.auth().currentUser else { - throw AuthServiceError.reauthenticationRequired("No user currently signed-in") - } - - guard let email = user.email else { - throw AuthServiceError.invalidCredentials("User does not have an email address") - } - - do { - let password = try await passwordPrompt.confirmPassword() - - let credential = EmailAuthProvider.credential(withEmail: email, password: password) - try await Auth.auth().currentUser?.reauthenticate(with: credential) - - return .firebase("") - } catch { - throw AuthServiceError.signInFailed(underlying: error) - } - } -} - -@MainActor -class EmailPasswordDeleteUserOperation: AuthenticatedOperation, - EmailPasswordOperationReauthentication { - let passwordPrompt: PasswordPromptCoordinator - - init(passwordPrompt: PasswordPromptCoordinator) { - self.passwordPrompt = passwordPrompt - } - - func callAsFunction(on user: User) async throws { - try await callAsFunction(on: user) { - try await user.delete() - } - } -} - -class EmailPasswordUpdatePasswordOperation: AuthenticatedOperation, - EmailPasswordOperationReauthentication { - let passwordPrompt: PasswordPromptCoordinator - let newPassword: String - - init(passwordPrompt: PasswordPromptCoordinator, newPassword: String) { - self.passwordPrompt = passwordPrompt - self.newPassword = newPassword - } - - func callAsFunction(on user: User) async throws { - try await callAsFunction(on: user) { - try await user.updatePassword(to: newPassword) - } - } -} - +/// Coordinator for prompting users to enter their password during reauthentication flows @MainActor @Observable public final class PasswordPromptCoordinator { @@ -96,7 +35,8 @@ public final class PasswordPromptCoordinator { func cancel() { continuation? - .resume(throwing: AuthServiceError.reauthenticationRequired("Password entry cancelled")) + .resume(throwing: AuthServiceError + .signInCancelled("Password entry cancelled for Email provider")) cleanup() } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AccountService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AccountService.swift deleted file mode 100644 index 327b66c77a..0000000000 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AccountService.swift +++ /dev/null @@ -1,52 +0,0 @@ -// 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 AuthenticationServices -import FirebaseAuth - -extension NSError { - var requiresReauthentication: Bool { - domain == AuthErrorDomain && code == AuthErrorCode.requiresRecentLogin.rawValue - } - - var credentialAlreadyInUse: Bool { - domain == AuthErrorDomain && code == AuthErrorCode.credentialAlreadyInUse.rawValue - } -} - -public enum AuthenticationToken { - case apple(ASAuthorizationAppleIDCredential, String) - case firebase(String) -} - -@MainActor -public protocol AuthenticatedOperation { - func callAsFunction(on user: User) async throws - func reauthenticate() async throws -> AuthenticationToken -} - -public extension AuthenticatedOperation { - func callAsFunction(on _: User, - _ performOperation: () async throws -> Void) async throws { - do { - try await performOperation() - } catch let error as NSError where error.requiresReauthentication { - let token = try await reauthenticate() - try await performOperation() - } catch AuthServiceError.reauthenticationRequired { - let token = try await reauthenticate() - try await performOperation() - } - } -} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift index c1d4b24e01..d760cc743f 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift @@ -25,6 +25,12 @@ public struct AuthConfiguration { public let emailLinkSignInActionCodeSettings: ActionCodeSettings? public let verifyEmailActionCodeSettings: ActionCodeSettings? + // MARK: - MFA Configuration + + public let mfaEnabled: Bool + public let allowedSecondFactors: Set + public let mfaIssuer: String + public init(shouldHideCancelButton: Bool = false, interactiveDismissEnabled: Bool = true, shouldAutoUpgradeAnonymousUsers: Bool = false, @@ -32,7 +38,10 @@ public struct AuthConfiguration { tosUrl: URL? = nil, privacyPolicyUrl: URL? = nil, emailLinkSignInActionCodeSettings: ActionCodeSettings? = nil, - verifyEmailActionCodeSettings: ActionCodeSettings? = nil) { + verifyEmailActionCodeSettings: ActionCodeSettings? = nil, + mfaEnabled: Bool = false, + allowedSecondFactors: Set = [.sms, .totp], + mfaIssuer: String = "Firebase Auth") { self.shouldHideCancelButton = shouldHideCancelButton self.interactiveDismissEnabled = interactiveDismissEnabled self.shouldAutoUpgradeAnonymousUsers = shouldAutoUpgradeAnonymousUsers @@ -41,5 +50,8 @@ public struct AuthConfiguration { self.privacyPolicyUrl = privacyPolicyUrl self.emailLinkSignInActionCodeSettings = emailLinkSignInActionCodeSettings self.verifyEmailActionCodeSettings = verifyEmailActionCodeSettings + self.mfaEnabled = mfaEnabled + self.allowedSecondFactors = allowedSecondFactors + self.mfaIssuer = mfaIssuer } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index b124e69b29..55b63c2983 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -13,25 +13,24 @@ // limitations under the License. @preconcurrency import FirebaseAuth +import FirebaseAuthUIComponents +import FirebaseCore import SwiftUI -public protocol ExternalAuthProvider { - var id: String { get } - @MainActor func authButton() -> AnyView +public protocol AuthProviderSwift { + @MainActor func createAuthCredential() async throws -> AuthCredential } -public protocol GoogleProviderAuthUIProtocol: ExternalAuthProvider { - @MainActor func signInWithGoogle(clientID: String) async throws -> AuthCredential - @MainActor func deleteUser(user: User) async throws -} - -public protocol FacebookProviderAuthUIProtocol: ExternalAuthProvider { - @MainActor func signInWithFacebook(isLimitedLogin: Bool) async throws -> AuthCredential - @MainActor func deleteUser(user: User) async throws +public protocol AuthProviderUI { + var id: String { get } + @MainActor func authButton() -> AnyView + var provider: AuthProviderSwift { get } } -public protocol PhoneAuthProviderAuthUIProtocol: ExternalAuthProvider { - @MainActor func verifyPhoneNumber(phoneNumber: String) async throws -> String +public protocol PhoneAuthProviderSwift: AuthProviderSwift, AnyObject { + // Phone auth provider that presents its own UI flow in createAuthCredential() + // Internal use only: AuthService will be injected automatically by AuthService.signIn() + var authService: AuthService? { get set } } public enum AuthenticationState { @@ -41,15 +40,22 @@ public enum AuthenticationState { } public enum AuthenticationFlow { - case login + case signIn case signUp } -public enum AuthView { - case authPicker +public enum AuthView: Hashable { case passwordRecovery case emailLink case updatePassword + case mfaEnrollment + case mfaManagement + case mfaResolution +} + +public enum SignInOutcome: @unchecked Sendable { + case mfaRequired(MFARequired) + case signedIn(AuthDataResult?) } @MainActor @@ -78,6 +84,24 @@ private final class AuthListenerManager { } } +@Observable +public class Navigator { + var routes: [AuthView] = [] + + public func push(_ route: AuthView) { + routes.append(route) + } + + @discardableResult + public func pop() -> AuthView? { + routes.popLast() + } + + public func clear() { + routes.removeAll() + } +} + @MainActor @Observable public final class AuthService { @@ -86,86 +110,53 @@ public final class AuthService { self.configuration = configuration string = StringUtils(bundle: configuration.customStringsBundle ?? Bundle.module) listenerManager = AuthListenerManager(auth: auth, authEnvironment: self) + FirebaseApp.registerLibrary("firebase-ui-ios", withVersion: FirebaseAuthSwiftUIVersion.version) } @ObservationIgnored @AppStorage("email-link") public var emailLink: String? public let configuration: AuthConfiguration public let auth: Auth - public var authView: AuthView = .authPicker - public let string: StringUtils - public var currentUser: User? - public var authenticationState: AuthenticationState = .unauthenticated - public var authenticationFlow: AuthenticationFlow = .login - public var errorMessage = "" - public let passwordPrompt: PasswordPromptCoordinator = .init() - - // MARK: - AuthPickerView Modal APIs - - public var isShowingAuthModal = false - - public enum AuthModalContentType { - case phoneAuth + public var isPresented: Bool = false + public private(set) var navigator = Navigator() + public var authView: AuthView? { + navigator.routes.last } - public var currentModal: AuthModalContentType? - - public var authModalViewBuilderRegistry: [AuthModalContentType: () -> AnyView] = [:] - - public func registerModalView(for type: AuthModalContentType, - @ViewBuilder builder: @escaping () -> AnyView) { - authModalViewBuilderRegistry[type] = builder + var authViewRoutes: [AuthView] { + navigator.routes } - public func viewForCurrentModal() -> AnyView? { - guard let type = currentModal, - let builder = authModalViewBuilderRegistry[type] else { - return nil - } - return builder() - } - - public func presentModal(for type: AuthModalContentType) { - currentModal = type - isShowingAuthModal = true - } - - public func dismissModal() { - isShowingAuthModal = false - } - - // MARK: - End AuthPickerView Modal APIs + public let string: StringUtils + public var currentUser: User? + public var authenticationState: AuthenticationState = .unauthenticated + public var authenticationFlow: AuthenticationFlow = .signIn + public var currentError: AlertError? + public let passwordPrompt: PasswordPromptCoordinator = .init() + public var currentMFARequired: MFARequired? + private var currentMFAResolver: MultiFactorResolver? // MARK: - Provider APIs - private var unsafeGoogleProvider: (any GoogleProviderAuthUIProtocol)? - private var unsafeFacebookProvider: (any FacebookProviderAuthUIProtocol)? - private var unsafePhoneAuthProvider: (any PhoneAuthProviderAuthUIProtocol)? - private var listenerManager: AuthListenerManager? - public var signedInCredential: AuthCredential? var emailSignInEnabled = false - private var providers: [ExternalAuthProvider] = [] - public func register(provider: ExternalAuthProvider) { - switch provider { - case let google as GoogleProviderAuthUIProtocol: - unsafeGoogleProvider = google - providers.append(provider) - case let facebook as FacebookProviderAuthUIProtocol: - unsafeFacebookProvider = facebook - providers.append(provider) - case let phone as PhoneAuthProviderAuthUIProtocol: - unsafePhoneAuthProvider = phone - providers.append(provider) - default: - break - } + private var providers: [AuthProviderUI] = [] + + public func registerProvider(providerWithButton: AuthProviderUI) { + providers.append(providerWithButton) } public func renderButtons(spacing: CGFloat = 16) -> AnyView { AnyView( VStack(spacing: spacing) { + AuthProviderButton( + label: string.signInWithEmailLinkViewTitle, + style: .email, + accessibilityId: "sign-in-with-email-link-button" + ) { + self.navigator.push(.emailLink) + } ForEach(providers, id: \.id) { provider in provider.authButton() } @@ -173,30 +164,20 @@ public final class AuthService { ) } - private var googleProvider: any GoogleProviderAuthUIProtocol { - get throws { - guard let provider = unsafeGoogleProvider else { - fatalError("`GoogleProviderAuthUI` has not been configured") - } - return provider - } - } - - private var facebookProvider: any FacebookProviderAuthUIProtocol { - get throws { - guard let provider = unsafeFacebookProvider else { - fatalError("`FacebookProviderAuthUI` has not been configured") + public func signIn(_ provider: AuthProviderSwift) async throws -> SignInOutcome { + do { + // Automatically inject AuthService for phone provider + if let phoneProvider = provider as? PhoneAuthProviderSwift { + phoneProvider.authService = self } - return provider - } - } - private var phoneAuthProvider: any PhoneAuthProviderAuthUIProtocol { - get throws { - guard let provider = unsafePhoneAuthProvider else { - fatalError("`PhoneAuthProviderAuthUI` has not been configured") - } - return provider + let credential = try await provider.createAuthCredential() + let result = try await signIn(credentials: credential) + return result + } catch { + // Always pass the underlying error - view decides what to show + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) + throw error } } @@ -223,7 +204,11 @@ public final class AuthService { } func reset() { - errorMessage = "" + currentError = nil + } + + func updateError(title: String = "Error", message: String, underlyingError: Error? = nil) { + currentError = AlertError(title: title, message: message, underlyingError: underlyingError) } public var shouldHandleAnonymousUpgrade: Bool { @@ -233,11 +218,11 @@ public final class AuthService { public func signOut() async throws { do { try await auth.signOut() + // Cannot wait for auth listener to change, feedback needs to be immediate + currentUser = nil updateAuthenticationState() } catch { - errorMessage = string.localizedErrorMessage( - for: error - ) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -245,29 +230,52 @@ public final class AuthService { public func linkAccounts(credentials credentials: AuthCredential) async throws { authenticationState = .authenticating do { - try await currentUser?.link(with: credentials) + guard let user = currentUser else { + throw AuthServiceError.noCurrentUser + } + + try await withReauthenticationIfNeeded(on: user) { + try await user.link(with: credentials) + } updateAuthenticationState() } catch { authenticationState = .unauthenticated - errorMessage = string.localizedErrorMessage( - for: error - ) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } - public func handleAutoUpgradeAnonymousUser(credentials: AuthCredential) async throws { + private func handleAutoUpgradeAnonymousUser(credentials: AuthCredential) async throws + -> SignInOutcome { if currentUser == nil { throw AuthServiceError.noCurrentUser } do { - try await currentUser?.link(with: credentials) + let result = try await currentUser?.link(with: credentials) + updateAuthenticationState() + return .signedIn(result) } catch let error as NSError { + // Handle credentialAlreadyInUse error + if error.code == AuthErrorCode.credentialAlreadyInUse.rawValue { + // Extract the updated credential from the error + let updatedCredential = error.userInfo["FIRAuthUpdatedCredentialKey"] as? AuthCredential + ?? credentials + + let context = AccountMergeConflictContext( + credential: updatedCredential, + underlyingError: error, + message: "Unable to merge accounts. The credential is already associated with a different account.", + uid: currentUser?.uid + ) + throw AuthServiceError.accountMergeConflict(context: context) + } + + // Handle emailAlreadyInUse error if error.code == AuthErrorCode.emailAlreadyInUse.rawValue { let context = AccountMergeConflictContext( credential: credentials, underlyingError: error, - message: "Unable to merge accounts. Use the credential in the context to resolve the conflict.", + message: "Unable to merge accounts. This email is already associated with a different account.", uid: currentUser?.uid ) throw AuthServiceError.accountMergeConflict(context: context) @@ -276,26 +284,34 @@ public final class AuthService { } } - public func signIn(credentials: AuthCredential) async throws { + public func signIn(credentials: AuthCredential) async throws -> SignInOutcome { authenticationState = .authenticating do { if shouldHandleAnonymousUpgrade { - try await handleAutoUpgradeAnonymousUser(credentials: credentials) + return try await handleAutoUpgradeAnonymousUser(credentials: credentials) } else { let result = try await auth.signIn(with: credentials) - signedInCredential = result.credential ?? credentials + updateAuthenticationState() + return .signedIn(result) } - updateAuthenticationState() - } catch { + } catch let error as NSError { authenticationState = .unauthenticated - errorMessage = string.localizedErrorMessage( - for: error - ) + // Check if this is an MFA required error + if error.code == AuthErrorCode.secondFactorRequired.rawValue { + if let resolver = error + .userInfo[AuthErrorUserInfoMultiFactorResolverKey] as? MultiFactorResolver { + return handleMFARequiredError(resolver: resolver) + } + } else { + // Don't want error modal on MFA error so we only update here + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) + } + throw error } } - func sendEmailVerification() async throws { + public func sendEmailVerification() async throws { do { if let user = currentUser { // Requires running on MainActor as passing to sendEmailVerification() which is non-isolated @@ -310,9 +326,7 @@ public final class AuthService { } } } catch { - errorMessage = string.localizedErrorMessage( - for: error - ) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -323,39 +337,30 @@ public final class AuthService { public extension AuthService { func deleteUser() async throws { do { - if let user = auth.currentUser, let providerId = signedInCredential?.provider { - if providerId == EmailAuthProviderID { - let operation = EmailPasswordDeleteUserOperation(passwordPrompt: passwordPrompt) - try await operation(on: user) - } else if providerId == FacebookAuthProviderID { - try await facebookProvider.deleteUser(user: user) - } else if providerId == GoogleAuthProviderID { - try await googleProvider.deleteUser(user: user) - } + guard let user = auth.currentUser else { + throw AuthServiceError.noCurrentUser } + try await withReauthenticationIfNeeded(on: user) { + try await user.delete() + } } catch { - errorMessage = string.localizedErrorMessage( - for: error - ) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } func updatePassword(to password: String) async throws { do { - if let user = auth.currentUser { - let operation = EmailPasswordUpdatePasswordOperation( - passwordPrompt: passwordPrompt, - newPassword: password - ) - try await operation(on: user) + guard let user = auth.currentUser else { + throw AuthServiceError.noCurrentUser } + try await withReauthenticationIfNeeded(on: user) { + try await user.updatePassword(to: password) + } } catch { - errorMessage = string.localizedErrorMessage( - for: error - ) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -369,39 +374,35 @@ public extension AuthService { return self } - func signIn(withEmail email: String, password: String) async throws { + func signIn(email: String, password: String) async throws -> SignInOutcome { let credential = EmailAuthProvider.credential(withEmail: email, password: password) - try await signIn(credentials: credential) + return try await signIn(credentials: credential) } - func createUser(withEmail email: String, password: String) async throws { + func createUser(email email: String, password: String) async throws -> SignInOutcome { authenticationState = .authenticating do { if shouldHandleAnonymousUpgrade { let credential = EmailAuthProvider.credential(withEmail: email, password: password) - try await handleAutoUpgradeAnonymousUser(credentials: credential) + return try await handleAutoUpgradeAnonymousUser(credentials: credential) } else { let result = try await auth.createUser(withEmail: email, password: password) - signedInCredential = result.credential + updateAuthenticationState() + return .signedIn(result) } - updateAuthenticationState() } catch { authenticationState = .unauthenticated - errorMessage = string.localizedErrorMessage( - for: error - ) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } - func sendPasswordRecoveryEmail(to email: String) async throws { + func sendPasswordRecoveryEmail(email: String) async throws { do { try await auth.sendPasswordReset(withEmail: email) } catch { - errorMessage = string.localizedErrorMessage( - for: error - ) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -410,7 +411,7 @@ public extension AuthService { // MARK: - Email Link Sign In public extension AuthService { - func sendEmailSignInLink(to email: String) async throws { + func sendEmailSignInLink(email: String) async throws { do { let actionCodeSettings = try updateActionCodeSettings() try await auth.sendSignInLink( @@ -418,9 +419,7 @@ public extension AuthService { actionCodeSettings: actionCodeSettings ) } catch { - errorMessage = string.localizedErrorMessage( - for: error - ) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -453,9 +452,7 @@ public extension AuthService { emailLink = nil } } catch { - errorMessage = string.localizedErrorMessage( - for: error - ) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -488,49 +485,494 @@ public extension AuthService { } } -// MARK: - Google Sign In +// MARK: - Phone Auth Sign In public extension AuthService { - func signInWithGoogle() async throws { - guard let clientID = auth.app?.options.clientID else { - throw AuthServiceError - .clientIdNotFound( - "OAuth client ID not found. Please make sure Google Sign-In is enabled in the Firebase console. You may have to download a new GoogleService-Info.plist file after enabling Google Sign-In." - ) + func verifyPhoneNumber(phoneNumber: String) async throws -> String { + return try await withCheckedThrowingContinuation { continuation in + PhoneAuthProvider.provider() + .verifyPhoneNumber(phoneNumber, uiDelegate: nil) { verificationID, error in + if let error = error { + continuation.resume(throwing: error) + return + } + continuation.resume(returning: verificationID!) + } } - let credential = try await googleProvider.signInWithGoogle(clientID: clientID) + } + func signInWithPhoneNumber(verificationID: String, verificationCode: String) async throws { + let credential = PhoneAuthProvider.provider() + .credential(withVerificationID: verificationID, verificationCode: verificationCode) try await signIn(credentials: credential) } } -// MARK: - Facebook Sign In +// MARK: - User Profile Management public extension AuthService { - func signInWithFacebook(limitedLogin: Bool = true) async throws { - let credential = try await facebookProvider - .signInWithFacebook(isLimitedLogin: limitedLogin) - try await signIn(credentials: credential) + func updateUserPhotoURL(url: URL) async throws { + guard let user = currentUser else { + throw AuthServiceError.noCurrentUser + } + + do { + let changeRequest = user.createProfileChangeRequest() + changeRequest.photoURL = url + try await changeRequest.commitChanges() + } catch { + updateError(message: string.localizedErrorMessage(for: error), underlyingError: 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 { + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) + throw error + } } } -// MARK: - Phone Auth Sign In +// MARK: - MFA Methods public extension AuthService { - func verifyPhoneNumber(phoneNumber: String) async throws -> String { + func startMfaEnrollment(type: SecondFactorType, accountName: String? = nil, + issuer: String? = nil) async throws -> EnrollmentSession { do { - return try await phoneAuthProvider.verifyPhoneNumber(phoneNumber: phoneNumber) + 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 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 + + // 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 + ) + + 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 + + let qrCodeURL = totpSecret.generateQRCodeURL( + withAccountName: resolvedAccountName, + issuer: resolvedIssuer + ) + + 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 + ) + } + } catch { + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) + throw error + } + } + + func sendSmsVerificationForEnrollment(session: EnrollmentSession, + phoneNumber: String) async throws -> String { + 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") + } + } + + // 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")) + } + } + } + + return verificationID + } catch { + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) + throw error + } + } + + func completeEnrollment(session: EnrollmentSession, verificationId: String?, + verificationCode: String, displayName: String) async throws { + 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") + } + + guard let user = auth.currentUser else { + throw AuthServiceError.noCurrentUser + } + + let multiFactorUser = user.multiFactor + + // 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") + } + + // 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") + } + + // 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") + } + + assertion = TOTPMultiFactorGenerator.assertionForEnrollment( + with: totpSecret, + oneTimePassword: verificationCode + ) + } + + // Complete the enrollment + try await withReauthenticationIfNeeded(on: user) { + try await user.multiFactor.enroll(with: assertion, displayName: displayName) + } + currentUser = auth.currentUser } catch { - errorMessage = string.localizedErrorMessage( - for: error + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) + throw error + } + } + + /// Gets the provider ID that was used for the current sign-in session + private func getCurrentSignInProvider() async throws -> String { + guard let user = currentUser else { + throw AuthServiceError.noCurrentUser + } + + // Get the ID token result which contains the signInProvider claim + let tokenResult = try await user.getIDTokenResult(forcingRefresh: false) + + // The signInProvider property tells us which provider was used for this session + let signInProvider = tokenResult.signInProvider + + // If signInProvider is not empty, use it + if !signInProvider.isEmpty { + return signInProvider + } + + // Fallback: if signInProvider is empty, try to infer from providerData + // Prefer non-password providers as they're more specific + let providerId = user.providerData.first(where: { $0.providerID != "password" })?.providerID + ?? user.providerData.first?.providerID + + guard let providerId = providerId else { + throw AuthServiceError.reauthenticationRequired( + "Unable to determine sign-in provider for reauthentication" ) + } + + return providerId + } + + func reauthenticateCurrentUser(on user: User) async throws { + // Get the provider from the token instead of stored credential + let providerId = try await getCurrentSignInProvider() + + if providerId == EmailAuthProviderID { + guard let email = user.email else { + throw AuthServiceError.invalidCredentials("User does not have an email address") + } + let password = try await passwordPrompt.confirmPassword() + let credential = EmailAuthProvider.credential(withEmail: email, password: password) + _ = try await user.reauthenticate(with: credential) + } else if let matchingProvider = providers.first(where: { $0.id == providerId }) { + let credential = try await matchingProvider.provider.createAuthCredential() + _ = try await user.reauthenticate(with: credential) + } else { + throw AuthServiceError.providerNotFound("No provider found for \(providerId)") + } + } + + private func withReauthenticationIfNeeded(on user: User, + operation: () async throws -> Void) async throws { + do { + try await operation() + } 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 operation() + } else { + throw error + } + } + } + + func unenrollMFA(_ factorUid: String) async throws -> [MultiFactorInfo] { + do { + guard let user = auth.currentUser else { + throw AuthServiceError.noCurrentUser + } + + let multiFactorUser = user.multiFactor + + try await withReauthenticationIfNeeded(on: user) { + try await multiFactorUser.unenroll(withFactorUID: factorUid) + } + + // This is the only we to get the actual latest enrolledFactors + currentUser = Auth.auth().currentUser + let freshFactors = currentUser?.multiFactor.enrolledFactors ?? [] + + return freshFactors + } catch { + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } - func signInWithPhoneNumber(verificationID: String, verificationCode: String) async throws { - let credential = PhoneAuthProvider.provider() - .credential(withVerificationID: verificationID, verificationCode: verificationCode) - try await signIn(credentials: credential) + // MARK: - MFA Helper Methods + + private func extractMFAHints(from resolver: MultiFactorResolver) -> [MFAHint] { + return resolver.hints.map { hint -> MFAHint in + if hint.factorID == PhoneMultiFactorID { + let phoneHint = hint as! PhoneMultiFactorInfo + return .phone( + displayName: phoneHint.displayName, + uid: phoneHint.uid, + phoneNumber: phoneHint.phoneNumber + ) + } else if hint.factorID == TOTPMultiFactorID { + return .totp( + displayName: hint.displayName, + uid: hint.uid + ) + } else { + // Fallback for unknown hint types + return .totp(displayName: hint.displayName, uid: hint.uid) + } + } + } + + private func handleMFARequiredError(resolver: MultiFactorResolver) -> SignInOutcome { + let hints = extractMFAHints(from: resolver) + currentMFARequired = MFARequired(hints: hints) + currentMFAResolver = resolver + navigator.push(.mfaResolution) + return .mfaRequired(MFARequired(hints: hints)) + } + + func resolveSmsChallenge(hintIndex: Int) async throws -> String { + 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") + } + + 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")) + } + } + } + } catch { + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) + throw error + } + } + + func resolveSignIn(code: String, hintIndex: Int, verificationId: String? = nil) async throws { + 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") + } + + 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") + } + + 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 { + throw AuthServiceError.multiFactorAuth("Unsupported MFA hint type") + } + + do { + let result = try await resolver.resolveSignIn(with: assertion) + updateAuthenticationState() + + // Clear MFA resolution state + currentMFARequired = nil + currentMFAResolver = nil + + } catch { + throw AuthServiceError + .multiFactorAuth("Failed to resolve MFA challenge: \(error.localizedDescription)") + } + } catch { + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) + throw error + } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Strings/Localizable.xcstrings b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Strings/Localizable.xcstrings index a9b077b883..7d686924a6 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Strings/Localizable.xcstrings +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Strings/Localizable.xcstrings @@ -3,6 +3,12 @@ "strings" : { "%@" : { + }, + "β€’β€’β€’β€’β€’β€’%@" : { + + }, + "Account: %@" : { + }, "AccountDisabledError" : { "comment" : "Error message displayed when the account is disabled. Use short/abbreviated translation for 'email' which is less than 15 chars.", @@ -27,6 +33,12 @@ } } } + }, + "Add an extra layer of security to your account" : { + + }, + "Add Another Method" : { + }, "AddPasswordAlertMessage" : { "comment" : "Alert message shown when adding account password.", @@ -162,6 +174,12 @@ } } } + }, + "Authentication Method" : { + + }, + "Authenticator App" : { + }, "AuthPickerTitle" : { "comment" : "Title for auth picker screen.", @@ -170,7 +188,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Welcome" + "value" : "Sign in with Firebase" } } } @@ -213,6 +231,15 @@ } } } + }, + "Change number" : { + + }, + "Choose Authentication Method" : { + + }, + "Choose verification method:" : { + }, "ChoosePassword" : { "comment" : "Placeholder for the password text field in a sign up form.", @@ -237,6 +264,15 @@ } } } + }, + "Complete Setup" : { + + }, + "Complete Sign-In" : { + + }, + "Complete sign-in with your second factor" : { + }, "ConfirmEmail" : { "comment" : "Title of confirm email label.", @@ -261,6 +297,9 @@ } } } + }, + "Copied to clipboard!" : { + }, "Delete" : { "comment" : "Text of Delete action button.", @@ -273,6 +312,12 @@ } } } + }, + "Delete Account" : { + + }, + "Delete Account?" : { + }, "DeleteAccountBody" : { "comment" : "Alert message body shown to confirm account deletion action.", @@ -321,6 +366,9 @@ } } } + }, + "Display Name" : { + }, "Don't have an account yet?" : { "localizations" : { @@ -439,6 +487,24 @@ } } } + }, + "Enrolled Methods" : { + + }, + "Enrolled: %@" : { + + }, + "Enter 6-digit code" : { + + }, + "Enter the 6-digit code from your authenticator app" : { + + }, + "Enter Verification Code" : { + + }, + "Enter Your Phone Number" : { + }, "EnterYourEmail" : { "comment" : "Title for email entry screen, email text field placeholder. Use short/abbreviated translation for 'email' which is less than 15 chars.", @@ -507,7 +573,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Forgot password?" + "value" : "Send password recovery email" } } } @@ -523,6 +589,9 @@ } } } + }, + "Get Started" : { + }, "InvalidEmailError" : { "comment" : "Error message displayed when user enters an invalid email address. Use short/abbreviated translation for 'email' which is less than 15 chars.", @@ -557,6 +626,21 @@ } } } + }, + "Manage Two-Factor Authentication" : { + + }, + "Manage your authentication methods" : { + + }, + "Manual Entry Key:" : { + + }, + "MFA is not enabled in the current configuration. Please contact your administrator." : { + + }, + "Multi-Factor Authentication Disabled" : { + }, "Name" : { "comment" : "Label next to a name text field.", @@ -581,6 +665,15 @@ } } } + }, + "No Authentication Methods" : { + + }, + "No Authentication Methods Available" : { + + }, + "No MFA methods are configured as allowed. Please contact your administrator." : { + }, "OK" : { "comment" : "OK button title.", @@ -653,6 +746,9 @@ } } } + }, + "Phone Number" : { + }, "PlaceholderChosePassword" : { "comment" : "Placeholder of secret input cell when user changes password.", @@ -797,6 +893,9 @@ } } } + }, + "Remove" : { + }, "Resend" : { "comment" : "Resend button title.", @@ -809,6 +908,9 @@ } } } + }, + "Resend Code" : { + }, "Save" : { "comment" : "Save button title.", @@ -821,6 +923,12 @@ } } } + }, + "Scan QR Code" : { + + }, + "Scan with your authenticator app or tap to open directly" : { + }, "Send" : { "comment" : "Send button title.", @@ -833,6 +941,9 @@ } } } + }, + "Send Code" : { + }, "SendEmailSignInLinkButtonLabel" : { "comment" : "Button label for sending email sign-in link", @@ -845,6 +956,12 @@ } } } + }, + "Set Up Two-Factor Authentication" : { + + }, + "Set up two-factor authentication to add an extra layer of security to your account." : { + }, "Sign up" : { @@ -944,6 +1061,18 @@ } } } + }, + "SMS Authentication" : { + + }, + "SMS Verification" : { + + }, + "SMS: %@" : { + + }, + "Tap to open in authenticator app" : { + }, "TermsOfService" : { "comment" : "Text linked to a web page with the Terms of Service content.", @@ -968,6 +1097,9 @@ } } } + }, + "This action cannot be undone. All your data will be permanently deleted. You may need to reauthenticate to complete this action." : { + }, "TroubleGettingEmailMessage" : { "comment" : "Alert message displayed when user having trouble getting email.", @@ -992,6 +1124,12 @@ } } } + }, + "Two-Factor Authentication" : { + + }, + "Unable to generate QR Code" : { + }, "UnlinkAction" : { "comment" : "Button title for unlinking account action.", @@ -1087,6 +1225,9 @@ } } } + }, + "Use an authenticator app like Google Authenticator or Authy to generate verification codes." : { + }, "UserNotFoundError" : { "comment" : "Error message displayed when there's no account matching the email address. Use short/abbreviated translation for 'email' which is less than 15 chars.", @@ -1099,6 +1240,9 @@ } } } + }, + "Verification Code" : { + }, "Verify email address?" : { "comment" : "Label for sending email verification to user.", @@ -1122,6 +1266,21 @@ } } } + }, + "We sent a code to %@" : { + + }, + "We'll send a code to β€’β€’β€’β€’β€’β€’%@" : { + + }, + "We'll send a verification code to this number" : { + + }, + "We'll send a verification code to your phone" : { + + }, + "We'll send a verification code to your phone number each time you sign in." : { + }, "WeakPasswordError" : { "comment" : "Error message displayed when the password is too weak.", diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Utils/PhoneUtils.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/PhoneUtils.swift similarity index 100% rename from FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Utils/PhoneUtils.swift rename to FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/PhoneUtils.swift diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/StringUtils.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/StringUtils.swift index f303855096..8b26f1f34c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/StringUtils.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/StringUtils.swift @@ -217,14 +217,14 @@ public class StringUtils { /// Account settings - send email verification label /// found in: - /// VerifyEmailView + /// SignedInView public var sendEmailVerificationButtonLabel: String { return localizedString(for: "Verify email address?") } /// Account settings - verify email sheet message /// found in: - /// VerifyEmailView + /// SignedInView public var verifyEmailSheetMessage: String { return localizedString(for: "Verification email sent") } @@ -323,6 +323,55 @@ public class StringUtils { return localizedString(for: "Enter phone number") } + /// Phone provider + /// found in: + /// - PhoneAuthView + public var phoneSignInTitle: String { + return localizedString(for: "Sign in with Phone") + } + + /// Phone provider + /// found in: + /// - PhoneAuthView + public var enterPhoneNumberPlaceholder: String { + return localizedString(for: "Enter phone number") + } + + /// Phone provider + /// found in: + /// - PhoneAuthView + public var sendCodeButtonLabel: String { + return localizedString(for: "Send Code") + } + + /// Phone provider + /// found in: + /// - PhoneAuthView + public var processingLabel: String { + return localizedString(for: "Processing...") + } + + /// Phone provider + /// found in: + /// - PhoneAuthView + public var enterVerificationCodeTitle: String { + return localizedString(for: "Enter Verification Code") + } + + /// Phone provider + /// found in: + /// - PhoneAuthView + public var verificationCodePlaceholder: String { + return localizedString(for: "Verification Code") + } + + /// Phone provider + /// found in: + /// - PhoneAuthView + public var verifyAndSignInButtonLabel: String { + return localizedString(for: "Verify and Sign In") + } + /// Phone provider /// found in: /// - PhoneAuthButtonView diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Version.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Version.swift new file mode 100644 index 0000000000..3b7cd58954 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Version.swift @@ -0,0 +1,19 @@ +// 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. + +// Auto-generated, do not edit manually. +public enum FirebaseAuthSwiftUIVersion { + // Use the release-swift.sh script to bump this version number, commit and push a new tag. + public static let version = "15.1.0" +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index e48b8ddc32..b8e1cdd05e 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -12,116 +12,169 @@ // See the License for the specific language governing permissions and // limitations under the License. +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI -@MainActor -public struct AuthPickerView { - @Environment(AuthService.self) private var authService +// MARK: - Merge Conflict Handling - public init() {} +/// Helper function to handle sign-in with automatic merge conflict resolution. +/// +/// This function attempts to sign in with the provided action. If a merge conflict occurs +/// (when an anonymous user is being upgraded and the credential is already associated with +/// an existing account), it automatically signs out the anonymous user and signs in with +/// the existing account's credential. +/// +/// - Parameters: +/// - authService: The AuthService instance to use for sign-in operations +/// - signInAction: An async closure that performs the sign-in operation +/// - Returns: The SignInOutcome from the successful sign-in +/// - Throws: Re-throws any errors except accountMergeConflict (which is handled internally) +@MainActor +public func signInWithMergeConflictHandling(authService: AuthService, + signInAction: () async throws + -> SignInOutcome) async throws -> SignInOutcome { + do { + return try await signInAction() + } catch let error as AuthServiceError { + if case let .accountMergeConflict(context) = error { + // The anonymous account conflicts with an existing account + // Sign out the anonymous user + try await authService.signOut() - private func switchFlow() { - authService.authenticationFlow = authService - .authenticationFlow == .login ? .signUp : .login + // Sign in with the existing account's credential + // This works because shouldHandleAnonymousUpgrade is now false after sign out + return try await authService.signIn(credentials: context.credential) + } + throw error } +} - private var isAuthModalPresented: Binding { - Binding( - get: { authService.isShowingAuthModal }, - set: { authService.isShowingAuthModal = $0 } - ) +// MARK: - Environment Key for Sign-In Handler + +/// Environment key for a sign-in handler that includes merge conflict resolution +private struct SignInHandlerKey: EnvironmentKey { + static let defaultValue: (@MainActor (AuthService, () async throws -> SignInOutcome) async throws + -> SignInOutcome)? = nil +} + +public extension EnvironmentValues { + /// A sign-in handler that automatically handles merge conflicts for anonymous user upgrades. + /// When set in the environment, views should use this handler to wrap their sign-in calls. + var signInWithMergeConflictHandler: (@MainActor (AuthService, + () async throws -> SignInOutcome) async throws + -> SignInOutcome)? { + get { self[SignInHandlerKey.self] } + set { self[SignInHandlerKey.self] = newValue } } +} - @ViewBuilder - private var authPickerTitleView: some View { - if authService.authView == .authPicker { - Text(authService.string.authPickerTitle) - .font(.largeTitle) - .fontWeight(.bold) - .padding() - } +@MainActor +public struct AuthPickerView { + public init(@ViewBuilder content: @escaping () -> Content = { EmptyView() }) { + self.content = content } + + @Environment(AuthService.self) private var authService + private let content: () -> Content } extension AuthPickerView: View { public var body: some View { - ScrollView { - VStack { - authPickerTitleView - if authService.authenticationState == .authenticated { - SignedInView() - } else { - switch authService.authView { - case .passwordRecovery: - PasswordRecoveryView() - case .emailLink: - EmailLinkView() - case .authPicker: - if authService.emailSignInEnabled { - Text(authService.authenticationFlow == .login ? authService.string - .emailLoginFlowLabel : authService.string.emailSignUpFlowLabel) - Divider() - EmailAuthView() + @Bindable var authService = authService + content() + .sheet(isPresented: $authService.isPresented) { + @Bindable var navigator = authService.navigator + NavigationStack(path: $navigator.routes) { + authPickerViewInternal + .navigationTitle(authService.authenticationState == .unauthenticated ? authService + .string.authPickerTitle : "") + .navigationBarTitleDisplayMode(.large) + .toolbar { + toolbar } - VStack { - authService.renderButtons() - }.padding(.horizontal) - if authService.emailSignInEnabled { - Divider() - HStack { - Text(authService - .authenticationFlow == .login ? authService.string.dontHaveAnAccountYetLabel : - authService.string.alreadyHaveAnAccountLabel) - Button(action: { - withAnimation { - switchFlow() - } - }) { - Text(authService.authenticationFlow == .signUp ? authService.string - .emailLoginFlowLabel : authService.string.emailSignUpFlowLabel) - .fontWeight(.semibold) - .foregroundColor(.blue) - }.accessibilityIdentifier("switch-auth-flow") + .navigationDestination(for: AuthView.self) { view in + switch view { + case AuthView.passwordRecovery: + PasswordRecoveryView() + case AuthView.emailLink: + EmailLinkView() + case AuthView.updatePassword: + UpdatePasswordView() + case AuthView.mfaEnrollment: + MFAEnrolmentView() + case AuthView.mfaManagement: + MFAManagementView() + case AuthView.mfaResolution: + MFAResolutionView() } } - 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() - } } - }.sheet(isPresented: isAuthModalPresented) { - VStack(spacing: 0) { - HStack { - Button(action: { - authService.dismissModal() - }) { - HStack(spacing: 4) { - Image(systemName: "chevron.left") - .font(.system(size: 17, weight: .medium)) - Text(authService.string.backButtonLabel) - .font(.system(size: 17)) - } - .foregroundColor(.blue) - } - Spacer() - } - .padding() - .background(Color(.systemBackground)) + .interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled) + } + } - Divider() + @ToolbarContentBuilder + var toolbar: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + if !authService.configuration.shouldHideCancelButton { + Button { + authService.isPresented = false + } label: { + Image(systemName: "xmark") + } + } + } + } - if let view = authService.viewForCurrentModal() { - view - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() + @ViewBuilder + var authPickerViewInternal: some View { + @Bindable var authService = authService + VStack { + if authService.authenticationState == .unauthenticated { + authMethodPicker + .safeAreaPadding() + } else { + SignedInView() + } + } + .errorAlert( + error: $authService.currentError, + okButtonLabel: authService.string.okButtonLabel + ) + } + + @ViewBuilder + var authMethodPicker: some View { + GeometryReader { proxy in + ScrollView { + VStack(spacing: 24) { + Image(Assets.firebaseAuthLogo) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 100, height: 100) + if authService.emailSignInEnabled { + EmailAuthView().environment( + \.signInWithMergeConflictHandler, + signInWithMergeConflictHandling + ) } + Divider() + otherSignInOptions(proxy) + PrivacyTOCsView(displayMode: .full) } } } } + + @ViewBuilder + func otherSignInOptions(_ proxy: GeometryProxy) -> some View { + VStack { + authService.renderButtons() + } + .padding(.horizontal, proxy.size.width * 0.18) + .environment(\.signInWithMergeConflictHandler, signInWithMergeConflictHandling) + } } #Preview { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index 739bbebe11..4bba09d475 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -19,6 +19,7 @@ // Created by Russell Wheatley on 20/03/2025. // import FirebaseAuth +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -31,6 +32,7 @@ private enum FocusableField: Hashable { @MainActor public struct EmailAuthView { @Environment(AuthService.self) private var authService + @Environment(\.signInWithMergeConflictHandler) private var signInHandler @State private var email = "" @State private var password = "" @@ -41,7 +43,7 @@ public struct EmailAuthView { public init() {} private var isValid: Bool { - return if authService.authenticationFlow == .login { + return if authService.authenticationFlow == .signIn { !email.isEmpty && !password.isEmpty } else { !email.isEmpty && !password.isEmpty && password == confirmPassword @@ -49,93 +51,107 @@ public struct EmailAuthView { } private func signInWithEmailPassword() async { - do { - try await authService.signIn(withEmail: email, password: password) - } catch {} + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(email: email, password: password) + } + } else { + try? await authService.signIn(email: email, password: password) + } } private func createUserWithEmailPassword() async { - do { - try await authService.createUser(withEmail: email, password: password) - } catch {} + if let handler = signInHandler { + try? await handler(authService) { + try await authService.createUser(email: email, password: password) + } + } else { + try? await authService.createUser(email: email, password: password) + } } } extension EmailAuthView: View { public var body: some View { - VStack { - LabeledContent { - TextField(authService.string.emailInputLabel, text: $email) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .focused($focus, equals: .email) - .submitLabel(.next) - .onSubmit { - self.focus = .password - } - } label: { - Image(systemName: "at") - } - .padding(.vertical, 6) - .background(Divider(), alignment: .bottom) - .padding(.bottom, 4) + VStack(spacing: 16) { + AuthTextField( + text: $email, + localizedTitle: "Email", + prompt: authService.string.emailInputLabel, + keyboardType: .emailAddress, + contentType: .emailAddress, + onSubmit: { _ in + self.focus = .password + }, + leading: { + Image(systemName: "at") + } + ) + .focused($focus, equals: .email) .accessibilityIdentifier("email-field") - - LabeledContent { - SecureField(authService.string.passwordInputLabel, text: $password) - .focused($focus, equals: .password) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .submitLabel(.go) - .onSubmit { - Task { await signInWithEmailPassword() } - } - } label: { - Image(systemName: "lock") - } - .padding(.vertical, 6) - .background(Divider(), alignment: .bottom) - .padding(.bottom, 8) + AuthTextField( + text: $password, + localizedTitle: "Password", + prompt: authService.string.passwordInputLabel, + contentType: .password, + sensitive: true, + onSubmit: { _ in + Task { await signInWithEmailPassword() } + }, + leading: { + Image(systemName: "lock") + } + ) + .submitLabel(.go) + .focused($focus, equals: .password) .accessibilityIdentifier("password-field") - - if authService.authenticationFlow == .login { - Button(action: { - authService.authView = .passwordRecovery - }) { + if authService.authenticationFlow == .signIn { + Button { + authService.navigator.push(.passwordRecovery) + } label: { Text(authService.string.passwordButtonLabel) - }.accessibilityIdentifier("password-recovery-button") + .frame(maxWidth: .infinity, alignment: .trailing) + } + .accessibilityIdentifier("password-recovery-button") } if authService.authenticationFlow == .signUp { - LabeledContent { - SecureField(authService.string.confirmPasswordInputLabel, text: $confirmPassword) - .focused($focus, equals: .confirmPassword) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .submitLabel(.go) - .onSubmit { - Task { await createUserWithEmailPassword() } - } - } label: { - Image(systemName: "lock") - } - .padding(.vertical, 6) - .background(Divider(), alignment: .bottom) - .padding(.bottom, 8) + AuthTextField( + text: $confirmPassword, + localizedTitle: "Confirm Password", + prompt: authService.string.confirmPasswordInputLabel, + contentType: .password, + sensitive: true, + onSubmit: { _ in + Task { await createUserWithEmailPassword() } + }, + leading: { + Image(systemName: "lock") + } + ) + .submitLabel(.go) + .focused($focus, equals: .confirmPassword) .accessibilityIdentifier("confirm-password-field") } Button(action: { Task { - if authService.authenticationFlow == .login { await signInWithEmailPassword() } - else { await createUserWithEmailPassword() } + if authService.authenticationFlow == .signIn { + await signInWithEmailPassword() + } else { + await createUserWithEmailPassword() + } } }) { if authService.authenticationState != .authenticating { - Text(authService.authenticationFlow == .login ? authService.string - .signInWithEmailButtonLabel : authService.string.signUpWithEmailButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) + Text( + authService.authenticationFlow == .signIn + ? authService.string + .signInWithEmailButtonLabel + : authService.string.signUpWithEmailButtonLabel + ) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) } else { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: .white)) @@ -144,15 +160,35 @@ extension EmailAuthView: View { } } .disabled(!isValid) - .padding([.top, .bottom, .horizontal], 8) + .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .buttonStyle(.borderedProminent) .accessibilityIdentifier("sign-in-button") + } + HStack { + Text( + authService + .authenticationFlow == .signIn + ? authService.string.dontHaveAnAccountYetLabel + : authService.string.alreadyHaveAnAccountLabel + ) Button(action: { - authService.authView = .emailLink + withAnimation { + authService.authenticationFlow = + authService + .authenticationFlow == .signIn ? .signUp : .signIn + } }) { - Text(authService.string.signUpWithEmailLinkButtonLabel) - }.accessibilityIdentifier("sign-in-with-email-link-button") + Text( + authService.authenticationFlow == .signUp + ? authService.string + .emailLoginFlowLabel + : authService.string.emailSignUpFlowLabel + ) + .fontWeight(.semibold) + .foregroundColor(.blue) + } + .accessibilityIdentifier("switch-auth-flow") } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift index 5e242f8d62..c86c33d64f 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift @@ -13,6 +13,7 @@ // limitations under the License. import FirebaseAuth +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -25,67 +26,69 @@ public struct EmailLinkView { private func sendEmailLink() async { do { - try await authService.sendEmailSignInLink(to: email) + try await authService.sendEmailSignInLink(email: email) showModal = true - } catch {} + } catch { + // Error already displayed via modal by AuthService + } } } extension EmailLinkView: View { public var body: some View { - VStack { - Text(authService.string.signInWithEmailLinkViewTitle) - .accessibilityIdentifier("email-link-title-text") - LabeledContent { - TextField(authService.string.emailInputLabel, text: $email) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .submitLabel(.next) - } label: { - Image(systemName: "at") - }.padding(.vertical, 6) - .background(Divider(), alignment: .bottom) - .padding(.bottom, 4) - Button(action: { + VStack(spacing: 24) { + AuthTextField( + text: $email, + localizedTitle: "Send a sign-in link to your email", + prompt: authService.string.emailInputLabel, + keyboardType: .emailAddress, + contentType: .emailAddress, + leading: { + Image(systemName: "at") + } + ) + Button { Task { await sendEmailLink() authService.emailLink = email } - }) { + } label: { Text(authService.string.sendEmailLinkButtonLabel) .padding(.vertical, 8) .frame(maxWidth: .infinity) } + .buttonStyle(.borderedProminent) .disabled(!CommonUtils.isValidEmail(email)) .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) - Text(authService.errorMessage).foregroundColor(.red) - }.sheet(isPresented: $showModal) { - VStack { + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle(authService.string.signInWithEmailLinkViewTitle) + .safeAreaPadding() + .sheet(isPresented: $showModal) { + VStack(spacing: 24) { Text(authService.string.signInWithEmailLinkViewMessage) - .padding() - Button(authService.string.okButtonLabel) { + .font(.headline) + Button { showModal = false + } label: { + Text(authService.string.okButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) } - .padding() + .buttonStyle(.borderedProminent) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) } - .padding() - }.onOpenURL { url in + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .safeAreaPadding() + .presentationDetents([.medium]) + } + .onOpenURL { url in Task { - do { - try await authService.handleSignInLink(url: url) - } catch {} + try? await authService.handleSignInLink(url: url) } } - .navigationBarItems(leading: Button(action: { - authService.authView = .authPicker - }) { - Image(systemName: "chevron.left") - .foregroundColor(.blue) - Text(authService.string.backButtonLabel) - .foregroundColor(.blue) - }.accessibilityIdentifier("email-link-back-button")) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift new file mode 100644 index 0000000000..ab1da65f8b --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift @@ -0,0 +1,62 @@ +// 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: { + // View layer decides: Don't show alert for CancellationError + guard let error = error else { return false } + return !(error.underlyingError is CancellationError) + }, + 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 let underlyingError: Error? + + public init(title: String = "Error", message: String, underlyingError: Error? = nil) { + self.title = title + self.message = message + self.underlyingError = underlyingError + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift new file mode 100644 index 0000000000..f53da9da72 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift @@ -0,0 +1,678 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +import FirebaseAuthUIComponents +import FirebaseCore +import SwiftUI + +private enum FocusableField: Hashable { + case phoneNumber + case verificationCode + case totpCode +} + +@MainActor +public struct MFAEnrolmentView { + @Environment(AuthService.self) private var authService + + @State private var selectedFactorType: SecondFactorType = .sms + @State private var phoneNumber = "" + @State private var selectedCountry: CountryData = .default + @State private var verificationCode = "" + @State private var totpCode = "" + @State private var currentSession: EnrollmentSession? + @State private var isLoading = false + @State private var displayName = "" + @State private var showCopiedFeedback = false + + @FocusState private var focus: FocusableField? + + public init() {} + + private var allowedFactorTypes: [SecondFactorType] { + return Array(authService.configuration.allowedSecondFactors).sorted { lhs, rhs in + // Sort SMS first, then TOTP + switch (lhs, rhs) { + case (.sms, .totp): return true + case (.totp, .sms): return false + default: return false + } + } + } + + private var canStartEnrollment: Bool { + !isLoading && currentSession == nil && authService.configuration.mfaEnabled + } + + private var canSendSMSVerification: Bool { + currentSession?.type == .sms && + currentSession?.status == .initiated && + !phoneNumber.isEmpty && + !displayName.isEmpty && + !isLoading + } + + private var canCompleteEnrollment: Bool { + guard let session = currentSession, !isLoading else { return false } + + switch session.type { + case .sms: + return session.status == .verificationSent && !verificationCode.isEmpty && !displayName + .isEmpty + case .totp: + return session.status == .initiated && !totpCode.isEmpty && !displayName.isEmpty + } + } + + private func startEnrollment() { + Task { + isLoading = true + defer { isLoading = false } + + let session = try await authService.startMfaEnrollment( + type: selectedFactorType, + accountName: authService.currentUser?.email, + issuer: authService.configuration.mfaIssuer + ) + currentSession = session + } + } + + private func sendSMSVerification() { + guard let session = currentSession else { return } + + Task { + isLoading = true + defer { isLoading = false } + + let fullPhoneNumber = selectedCountry.dialCode + phoneNumber + let verificationId = try await authService.sendSmsVerificationForEnrollment( + session: session, + phoneNumber: fullPhoneNumber + ) + // Update session status + currentSession = EnrollmentSession( + id: session.id, + type: session.type, + session: session.session, + totpInfo: session.totpInfo, + phoneNumber: fullPhoneNumber, + verificationId: verificationId, + status: .verificationSent, + createdAt: session.createdAt, + expiresAt: session.expiresAt + ) + } + } + + private func completeEnrollment() { + guard let session = currentSession else { return } + + Task { + isLoading = true + defer { isLoading = false } + + let code = session.type == .sms ? verificationCode : totpCode + try await authService.completeEnrollment( + session: session, + verificationId: session.verificationId, + verificationCode: code, + displayName: displayName + ) + + // Reset form state on success + resetForm() + + authService.navigator.clear() + } + } + + private func resetForm() { + currentSession = nil + phoneNumber = "" + selectedCountry = .default + verificationCode = "" + totpCode = "" + displayName = "" + focus = nil + } + + private func copyToClipboard(_ text: String) { + UIPasteboard.general.string = text + + // Show feedback + showCopiedFeedback = true + + // Quickly show it has been copied to the clipboard + Task { + try? await Task.sleep(nanoseconds: 500_000_000) + showCopiedFeedback = false + } + } + + private func generateQRCode(from string: String) -> UIImage? { + let data = Data(string.utf8) + + guard let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil } + filter.setValue(data, forKey: "inputMessage") + filter.setValue("H", forKey: "inputCorrectionLevel") + + guard let ciImage = filter.outputImage else { return nil } + + // Scale up the QR code for better quality + let transform = CGAffineTransform(scaleX: 10, y: 10) + let scaledImage = ciImage.transformed(by: transform) + + let context = CIContext() + guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { + return nil + } + + return UIImage(cgImage: cgImage) + } +} + +extension MFAEnrolmentView: View { + public var body: some View { + VStack(spacing: 24) { + // Header (only shown when no session is active) + if currentSession == nil { + VStack(spacing: 8) { + Text("Set Up Two-Factor Authentication") + .font(.largeTitle) + .fontWeight(.bold) + .multilineTextAlignment(.center) + + Text("Add an extra layer of security to your account") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + + // Factor Type Selection (only if no session started) + if currentSession == nil { + if !authService.configuration.mfaEnabled { + VStack(spacing: 12) { + Image(systemName: "lock.slash") + .font(.system(size: 40)) + .foregroundColor(.orange) + + Text("Multi-Factor Authentication Disabled") + .font(.title2) + .fontWeight(.semibold) + + Text( + "MFA is not enabled in the current configuration. Please contact your administrator." + ) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .accessibilityIdentifier("mfa-disabled-message") + } else if allowedFactorTypes.isEmpty { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 40)) + .foregroundColor(.orange) + + Text("No Authentication Methods Available") + .font(.title2) + .fontWeight(.semibold) + + Text("No MFA methods are configured as allowed. Please contact your administrator.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .accessibilityIdentifier("no-factors-message") + } else { + VStack(alignment: .leading, spacing: 12) { + Text("Choose Authentication Method") + .font(.headline) + + Picker("Authentication Method", selection: $selectedFactorType) { + ForEach(allowedFactorTypes, id: \.self) { factorType in + switch factorType { + case .sms: + Image(systemName: "message").tag(SecondFactorType.sms) + case .totp: + Image(systemName: "qrcode").tag(SecondFactorType.totp) + } + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("factor-type-picker") + } + } + } + + // Content based on current state + if let session = currentSession { + enrollmentContent(for: session) + } else { + initialContent + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .safeAreaPadding() + .navigationTitle("Two-Factor Authentication") + .onAppear { + // Initialize selected factor type to first allowed type + if !allowedFactorTypes.contains(selectedFactorType), + let firstAllowed = allowedFactorTypes.first { + selectedFactorType = firstAllowed + } + } + } + + @ViewBuilder + private var initialContent: some View { + VStack(spacing: 24) { + // Description based on selected type + if selectedFactorType == .sms { + VStack(spacing: 8) { + Image(systemName: "message.circle") + .font(.system(size: 40)) + .foregroundColor(.blue) + + Text("SMS Authentication") + .font(.title2) + .fontWeight(.semibold) + + Text("We'll send a verification code to your phone number each time you sign in.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } else { + VStack(spacing: 8) { + Image(systemName: "qrcode") + .font(.system(size: 40)) + .foregroundColor(.green) + + Text("Authenticator App") + .font(.title2) + .fontWeight(.semibold) + + Text( + "Use an authenticator app like Google Authenticator or Authy to generate verification codes." + ) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + + Button { + startEnrollment() + } label: { + HStack { + if isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Get Started") + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(!canStartEnrollment) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("start-enrollment-button") + } + } + + @ViewBuilder + private func enrollmentContent(for session: EnrollmentSession) -> some View { + switch session.type { + case .sms: + smsEnrollmentContent(session: session) + case .totp: + totpEnrollmentContent(session: session) + } + } + + @ViewBuilder + private func smsEnrollmentContent(session: EnrollmentSession) -> some View { + VStack(spacing: 24) { + // SMS enrollment steps + if session.status == .initiated { + VStack(spacing: 24) { + VStack(spacing: 8) { + Image(systemName: "phone") + .font(.system(size: 48)) + .foregroundColor(.blue) + + Text("Enter Your Phone Number") + .font(.title2) + .fontWeight(.semibold) + + Text("We'll send a verification code to this number") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + + AuthTextField( + text: $phoneNumber, + localizedTitle: "Phone Number", + prompt: "Enter phone number", + keyboardType: .phonePad, + contentType: .telephoneNumber, + onChange: { _ in } + ) { + CountrySelector( + selectedCountry: $selectedCountry, + enabled: !isLoading + ) + } + .focused($focus, equals: .phoneNumber) + .accessibilityIdentifier("phone-number-field") + + AuthTextField( + text: $displayName, + localizedTitle: "Display Name", + prompt: "Enter display name for this device", + leading: { + Image(systemName: "person") + } + ) + .accessibilityIdentifier("display-name-field") + + Button { + sendSMSVerification() + } label: { + HStack { + if isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Send Code") + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(!canSendSMSVerification) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("send-sms-button") + } + } else if session.status == .verificationSent { + VStack(spacing: 24) { + VStack(spacing: 8) { + Image(systemName: "checkmark.message") + .font(.system(size: 48)) + .foregroundColor(.green) + + Text("Enter Verification Code") + .font(.title2) + .fontWeight(.semibold) + + Text("We sent a code to \(session.phoneNumber ?? "your phone")") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + + AuthTextField( + text: $verificationCode, + localizedTitle: "Verification Code", + prompt: "Enter 6-digit code", + keyboardType: .numberPad, + contentType: .oneTimeCode, + leading: { + Image(systemName: "number") + } + ) + .focused($focus, equals: .verificationCode) + .accessibilityIdentifier("verification-code-field") + + Button { + completeEnrollment() + } label: { + HStack { + if isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Complete Setup") + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(!canCompleteEnrollment) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("complete-enrollment-button") + + Button { + sendSMSVerification() + } label: { + Text("Resend Code") + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("resend-code-button") + } + } + } + } + + @ViewBuilder + private func totpEnrollmentContent(session: EnrollmentSession) -> some View { + VStack(spacing: 16) { + if let totpInfo = session.totpInfo { + VStack(spacing: 16) { + VStack(spacing: 6) { + Image(systemName: "qrcode") + .font(.system(size: 40)) + .foregroundColor(.green) + + Text("Scan QR Code") + .font(.title2) + .fontWeight(.semibold) + + Text("Scan with your authenticator app or tap to open directly") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .lineLimit(nil) + .padding(.horizontal) + } + + // QR Code generated from the otpauth:// URI + if let qrURL = totpInfo.qrCodeURL, + let qrImage = generateQRCode(from: qrURL.absoluteString) { + Button(action: { + UIApplication.shared.open(qrURL) + }) { + VStack(spacing: 8) { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 180, height: 180) + .accessibilityIdentifier("qr-code-image") + + HStack(spacing: 6) { + Image(systemName: "arrow.up.forward.app.fill") + .font(.caption) + Text("Tap to open in authenticator app") + .font(.caption) + .fontWeight(.medium) + } + .foregroundColor(.blue) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("open-authenticator-button") + } else { + RoundedRectangle(cornerRadius: 8) + .fill(Color.gray.opacity(0.3)) + .frame(width: 180, height: 180) + .overlay( + VStack { + Image(systemName: "exclamationmark.triangle") + .font(.title) + .foregroundColor(.orange) + Text("Unable to generate QR Code") + .font(.caption) + } + ) + } + + VStack(spacing: 6) { + Text("Manual Entry Key:") + .font(.headline) + + Button(action: { + copyToClipboard(totpInfo.sharedSecretKey) + }) { + HStack { + Text(totpInfo.sharedSecretKey) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + .minimumScaleFactor(0.5) + + Spacer() + + Image(systemName: showCopiedFeedback ? "checkmark" : "doc.on.doc") + .foregroundColor(showCopiedFeedback ? .green : .blue) + } + .padding() + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + .buttonStyle(.plain) + .accessibilityIdentifier("totp-secret-key") + + if showCopiedFeedback { + Text("Copied to clipboard!") + .font(.caption) + .foregroundColor(.green) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.2), value: showCopiedFeedback) + + AuthTextField( + text: $displayName, + localizedTitle: "Display Name", + prompt: "Enter display name for this authenticator", + leading: { + Image(systemName: "person") + } + ) + .accessibilityIdentifier("display-name-field") + + AuthTextField( + text: $totpCode, + localizedTitle: "Verification Code", + prompt: "Enter code from app", + keyboardType: .numberPad, + contentType: .oneTimeCode, + leading: { + Image(systemName: "number") + } + ) + .focused($focus, equals: .totpCode) + .accessibilityIdentifier("totp-code-field") + + Button { + completeEnrollment() + } label: { + HStack { + if isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Complete Setup") + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(!canCompleteEnrollment) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("complete-enrollment-button") + } + } + } + } +} + +#Preview("MFA Enabled - Both Methods") { + FirebaseOptions.dummyConfigurationForPreview() + let config = AuthConfiguration( + mfaEnabled: true, + allowedSecondFactors: [.sms, .totp] + ) + let authService = AuthService(configuration: config) + return MFAEnrolmentView() + .environment(authService) +} + +#Preview("MFA Disabled") { + FirebaseOptions.dummyConfigurationForPreview() + let config = AuthConfiguration( + mfaEnabled: false, + allowedSecondFactors: [] + ) + let authService = AuthService(configuration: config) + return MFAEnrolmentView() + .environment(authService) +} + +#Preview("No Allowed Factors") { + FirebaseOptions.dummyConfigurationForPreview() + let config = AuthConfiguration( + mfaEnabled: true, + allowedSecondFactors: [] + ) + let authService = AuthService(configuration: config) + return MFAEnrolmentView() + .environment(authService) +} + +#Preview("SMS Only") { + FirebaseOptions.dummyConfigurationForPreview() + let config = AuthConfiguration( + mfaEnabled: true, + allowedSecondFactors: [.sms] + ) + let authService = AuthService(configuration: config) + return MFAEnrolmentView() + .environment(authService) +} + +#Preview("TOTP Only") { + FirebaseOptions.dummyConfigurationForPreview() + let config = AuthConfiguration( + mfaEnabled: true, + allowedSecondFactors: [.totp] + ) + let authService = AuthService(configuration: config) + return MFAEnrolmentView() + .environment(authService) +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift new file mode 100644 index 0000000000..fcbed901a8 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift @@ -0,0 +1,211 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +import FirebaseCore +import SwiftUI + +extension MultiFactorInfo: Identifiable { + public var id: String { uid } +} + +@MainActor +public struct MFAManagementView { + @Environment(AuthService.self) private var authService + + @State private var enrolledFactors: [MultiFactorInfo] = [] + @State private var isLoading = false + + public init() {} + + private func loadEnrolledFactors() { + guard let user = authService.currentUser else { return } + enrolledFactors = user.multiFactor.enrolledFactors + } + + private func unenrollFactor(_ factorUid: String) { + Task { + isLoading = true + + do { + let freshFactors = try await authService.unenrollMFA(factorUid) + enrolledFactors = freshFactors + isLoading = false + } catch { + isLoading = false + } + } + } + + private func navigateToEnrollment() { + authService.navigator.push(.mfaEnrollment) + } +} + +extension MFAManagementView: View { + public var body: some View { + @Bindable var passwordPrompt = authService.passwordPrompt + VStack(spacing: 20) { + // Title section + VStack { + Text("Two-Factor Authentication") + .font(.largeTitle) + .fontWeight(.bold) + .multilineTextAlignment(.center) + + Text("Manage your authentication methods") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal) + + if enrolledFactors.isEmpty { + // No factors enrolled + VStack(spacing: 16) { + Image(systemName: "shield.slash") + .font(.system(size: 48)) + .foregroundColor(.orange) + + Text("No Authentication Methods") + .font(.title2) + .fontWeight(.semibold) + + Text( + "Set up two-factor authentication to add an extra layer of security to your account." + ) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button { + navigateToEnrollment() + } label: { + Text("Set Up Two-Factor Authentication") + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("setup-mfa-button") + } + } else { + // Show enrolled factors + VStack(alignment: .leading, spacing: 16) { + Text("Enrolled Methods") + .font(.headline) + .padding(.horizontal) + + ForEach(enrolledFactors) { factor in + factorRow(factor: factor) + } + + Divider() + .padding(.horizontal) + + Button("Add Another Method") { + navigateToEnrollment() + } + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("add-mfa-method-button") + } + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .safeAreaPadding() + .onAppear { + loadEnrolledFactors() + } + // Present password prompt when required for reauthentication + .sheet(isPresented: $passwordPrompt.isPromptingPassword) { + PasswordPromptSheet(coordinator: authService.passwordPrompt) + } + } + + @ViewBuilder + private func factorRow(factor: MultiFactorInfo) -> some View { + HStack { + // Factor type icon + Group { + if factor.factorID == PhoneMultiFactorID { + Image(systemName: "message") + .foregroundColor(.blue) + } else { + Image(systemName: "qrcode") + .foregroundColor(.green) + } + } + .font(.title2) + + VStack(alignment: .leading, spacing: 4) { + Text(factor.displayName ?? "Unnamed Method") + .font(.headline) + + if factor.factorID == PhoneMultiFactorID { + let phoneInfo = factor as! PhoneMultiFactorInfo + Text("SMS: \(phoneInfo.phoneNumber)") + .font(.caption) + .foregroundColor(.secondary) + } else { + Text("Authenticator App") + .font(.caption) + .foregroundColor(.secondary) + } + + Text("Enrolled: \(DateFormatter.shortDate.string(from: factor.enrollmentDate))") + .font(.caption2) + .foregroundColor(.secondary) + } + + Spacer() + + Button("Remove") { + unenrollFactor(factor.uid) + } + .buttonStyle(.bordered) + .foregroundColor(.red) + .disabled(isLoading) + .accessibilityIdentifier("remove-factor-\(factor.uid)") + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + .padding(.horizontal) + } +} + +// MARK: - Date Formatter Extension + +private extension DateFormatter { + static let shortDate: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .none + return formatter + }() +} + +#Preview { + FirebaseOptions.dummyConfigurationForPreview() + return NavigationStack { + MFAManagementView() + .environment(AuthService()) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift new file mode 100644 index 0000000000..a5efb32640 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift @@ -0,0 +1,398 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +import FirebaseCore +import SwiftUI + +private enum FocusableField: Hashable { + case verificationCode + case totpCode +} + +@MainActor +public struct MFAResolutionView { + @Environment(AuthService.self) private var authService + + @State private var verificationCode = "" + @State private var totpCode = "" + @State private var isLoading = false + @State private var selectedHintIndex = 0 + @State private var verificationId: String? + + @FocusState private var focus: FocusableField? + + public init() {} + + private var mfaRequired: MFARequired? { + // This would be set by the sign-in flow when MFA is required + authService.currentMFARequired + } + + private var selectedHint: MFAHint? { + guard let mfaRequired = mfaRequired, + selectedHintIndex < mfaRequired.hints.count else { + return nil + } + return mfaRequired.hints[selectedHintIndex] + } + + private var canCompleteResolution: Bool { + guard !isLoading else { return false } + + switch selectedHint { + case .phone: + return !verificationCode.isEmpty + case .totp: + return !totpCode.isEmpty + case .none: + return false + } + } + + private func startSMSChallenge() { + guard selectedHintIndex < (mfaRequired?.hints.count ?? 0) else { return } + + Task { + isLoading = true + + do { + let verificationId = try await authService.resolveSmsChallenge(hintIndex: selectedHintIndex) + self.verificationId = verificationId + isLoading = false + } catch { + isLoading = false + } + } + } + + private func completeResolution() { + Task { + isLoading = true + + do { + let code = selectedHint?.isPhoneHint == true ? verificationCode : totpCode + try await authService.resolveSignIn( + code: code, + hintIndex: selectedHintIndex, + verificationId: verificationId + ) + // On success, the AuthService will update the authentication state + // and we should navigate back to the main app + authService.navigator.clear() + isLoading = false + } catch { + isLoading = false + } + } + } + + private func cancelResolution() { + authService.navigator.clear() + } +} + +extension MFAResolutionView: View { + public var body: some View { + VStack(spacing: 24) { + // Header + VStack(spacing: 12) { + Image(systemName: "lock.shield") + .font(.system(size: 50)) + .foregroundColor(.blue) + + Text("Two-Factor Authentication") + .font(.largeTitle) + .fontWeight(.bold) + .accessibilityIdentifier("mfa-resolution-title") + + 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) + } + + // Resolution Content + if let hint = selectedHint { + resolutionContent(for: hint) + } + + // Action buttons + VStack(spacing: 12) { + // Complete Resolution Button + Button(action: completeResolution) { + HStack { + if isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Complete Sign-In") + } + .frame(maxWidth: .infinity) + .padding() + .background(canCompleteResolution ? Color.blue : Color.gray) + .foregroundColor(.white) + .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) + } + .padding(.vertical, 20) + } + + @ViewBuilder + private func resolutionContent(for hint: MFAHint) -> some View { + switch hint { + case let .phone(displayName, _, phoneNumber): + phoneResolutionContent(displayName: displayName, phoneNumber: phoneNumber) + case let .totp(displayName, _): + totpResolutionContent(displayName: displayName) + } + } + + @ViewBuilder + private func phoneResolutionContent(displayName _: String?, phoneNumber: String?) -> some View { + VStack(spacing: 16) { + VStack(spacing: 8) { + Image(systemName: "message.circle.fill") + .font(.system(size: 40)) + .foregroundColor(.blue) + + Text("SMS Verification") + .font(.title2) + .fontWeight(.semibold) + + if let phoneNumber = phoneNumber { + Text("We'll send a code to β€’β€’β€’β€’β€’β€’\(String(phoneNumber.suffix(4)))") + .font(.body) + .foregroundColor(.secondary) + } else { + Text("We'll send a verification code to your phone") + .font(.body) + .foregroundColor(.secondary) + } + } + .padding(.horizontal) + + // Send SMS button (if verification ID not yet obtained) + if verificationId == nil { + Button(action: startSMSChallenge) { + HStack { + if isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Send Code") + } + .frame(maxWidth: .infinity) + .padding() + .background(isLoading ? Color.gray : Color.blue) + .foregroundColor(.white) + .cornerRadius(8) + } + .disabled(isLoading) + .padding(.horizontal) + .accessibilityIdentifier("send-sms-button") + } else { + // Verification code input + VStack(alignment: .leading, spacing: 8) { + Text("Verification Code") + .font(.headline) + + TextField("Enter 6-digit code", text: $verificationCode) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .keyboardType(.numberPad) + .focused($focus, equals: .verificationCode) + .accessibilityIdentifier("sms-verification-code-field") + } + .padding(.horizontal) + } + } + } + + @ViewBuilder + private func totpResolutionContent(displayName: String?) -> some View { + VStack(spacing: 16) { + VStack(spacing: 8) { + Image(systemName: "qrcode") + .font(.system(size: 40)) + .foregroundColor(.green) + + Text("Authenticator App") + .font(.title2) + .fontWeight(.semibold) + + Text("Enter the 6-digit code from your authenticator app") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + if let displayName = displayName { + Text("Account: \(displayName)") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.horizontal) + + // TOTP code input + VStack(alignment: .leading, spacing: 8) { + Text("Verification Code") + .font(.headline) + + TextField("Enter 6-digit code", text: $totpCode) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .keyboardType(.numberPad) + .focused($focus, equals: .totpCode) + .accessibilityIdentifier("totp-verification-code-field") + } + .padding(.horizontal) + } + } + + @ViewBuilder + private func mfaHintsSelectionView(mfaRequired: MFARequired) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("Choose verification method:") + .font(.headline) + .padding(.horizontal) + + // More idiomatic approach using indices + ForEach(mfaRequired.hints.indices, id: \.self) { index in + let hint = mfaRequired.hints[index] + hintSelectionButton(hint: hint, index: index) + } + } + } + + @ViewBuilder + private func hintSelectionButton(hint: MFAHint, index: Int) -> some View { + Button(action: { + selectedHintIndex = index + // Clear previous input when switching methods + verificationCode = "" + totpCode = "" + verificationId = nil + }) { + HStack { + Image(systemName: hint.isPhoneHint ? "message.circle" : "qrcode") + .foregroundColor(.blue) + + VStack(alignment: .leading) { + Text(hintDisplayName(for: hint)) + .font(.body) + .foregroundColor(.primary) + + hintSubtitle(for: hint) + } + + Spacer() + + if selectedHintIndex == index { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.blue) + } + } + .padding() + .background(selectedHintIndex == index ? Color.blue.opacity(0.1) : Color.clear) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(selectedHintIndex == index ? Color.blue : Color.gray.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(PlainButtonStyle()) + .padding(.horizontal) + .accessibilityIdentifier("hint-\(index)") + } + + private func hintDisplayName(for hint: MFAHint) -> String { + hint.isPhoneHint ? "SMS" : "Authenticator App" + } + + @ViewBuilder + private func hintSubtitle(for hint: MFAHint) -> some View { + if case let .phone(_, _, phoneNumber) = hint, let phone = phoneNumber { + Text("β€’β€’β€’β€’β€’β€’\(String(phone.suffix(4)))") + .font(.caption) + .foregroundColor(.secondary) + } + } +} + +// Helper extension for MFAHint +private extension MFAHint { + var isPhoneHint: Bool { + switch self { + case .phone: + return true + case .totp: + return false + } + } +} + +#Preview("Phone SMS Only") { + FirebaseOptions.dummyConfigurationForPreview() + let authService = AuthService() + authService.currentMFARequired = MFARequired(hints: [ + .phone(displayName: "Work Phone", uid: "phone-uid-1", phoneNumber: "+15551234567"), + ]) + return MFAResolutionView().environment(authService) +} + +#Preview("TOTP Only") { + FirebaseOptions.dummyConfigurationForPreview() + let authService = AuthService() + authService.currentMFARequired = MFARequired(hints: [ + .totp(displayName: "Authenticator App", uid: "totp-uid-1"), + ]) + return MFAResolutionView().environment(authService) +} + +#Preview("Multiple Methods") { + FirebaseOptions.dummyConfigurationForPreview() + let authService = AuthService() + authService.currentMFARequired = MFARequired(hints: [ + .phone(displayName: "Mobile", uid: "phone-uid-1", phoneNumber: "+15551234567"), + .totp(displayName: "Google Authenticator", uid: "totp-uid-1"), + ]) + return MFAResolutionView().environment(authService) +} + +#Preview("No MFA Required") { + FirebaseOptions.dummyConfigurationForPreview() + let authService = AuthService() + // currentMFARequired is nil by default + return MFAResolutionView().environment(authService) +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift index d58f2be1f7..5c8296ed1a 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift @@ -12,56 +12,42 @@ // See the License for the specific language governing permissions and // limitations under the License. +import FirebaseAuthUIComponents 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(to: email) - resultWrapper = ResultWrapper(value: .success(())) + try await authService.sendPasswordRecoveryEmail(email: email) + sentEmail = email + showSuccessSheet = true } catch { - resultWrapper = ResultWrapper(value: .failure(error)) + // Error already displayed via modal by AuthService } } } extension PasswordRecoveryView: View { public var body: some View { - VStack { - Text(authService.string.passwordRecoveryTitle) - .font(.largeTitle) - .fontWeight(.bold) - .padding() - .accessibilityIdentifier("password-recovery-text") - - Divider() - - LabeledContent { - TextField(authService.string.emailInputLabel, text: $email) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .submitLabel(.next) - } label: { - Image(systemName: "at") - } - .padding(.vertical, 6) - .background(Divider(), alignment: .bottom) - .padding(.bottom, 4) - + VStack(spacing: 24) { + AuthTextField( + text: $email, + localizedTitle: "Send a password recovery link to your email", + prompt: authService.string.emailInputLabel, + keyboardType: .emailAddress, + contentType: .emailAddress, + leading: { + Image(systemName: "at") + } + ) Button(action: { Task { await sendPasswordRecoveryEmail() @@ -72,57 +58,39 @@ extension PasswordRecoveryView: View { .frame(maxWidth: .infinity) } .disabled(!CommonUtils.isValidEmail(email)) - .padding([.top, .bottom, .horizontal], 8) .frame(maxWidth: .infinity) .buttonStyle(.borderedProminent) } - .sheet(item: $resultWrapper) { wrapper in - resultSheet(wrapper.value) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle(authService.string.passwordRecoveryTitle) + .safeAreaPadding() + .sheet(isPresented: $showSuccessSheet) { + successSheet } - .navigationBarItems(leading: Button(action: { - authService.authView = .authPicker - }) { - Image(systemName: "chevron.left") - .foregroundColor(.blue) - Text(authService.string.backButtonLabel) - .foregroundColor(.blue) - }.accessibilityIdentifier("password-recovery-back-button")) } @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.navigator.clear() } .padding() } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift index db7c1294bd..d8a4da1be1 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PrivacyTOCsView.swift @@ -66,9 +66,7 @@ extension PrivacyTOCsView: View { if let tosURL = authService.configuration.tosUrl, let privacyURL = authService.configuration.privacyPolicyUrl { Text(attributedMessage(tosURL: tosURL, privacyURL: privacyURL)) - .multilineTextAlignment(displayMode == .full ? .leading : .trailing) - .font(.footnote) - .foregroundColor(.primary) + .multilineTextAlignment(displayMode == .full ? .center : .trailing) .padding() } else { EmptyView() diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift index a8d3acca9f..2ac346b392 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift @@ -18,57 +18,190 @@ import SwiftUI @MainActor public struct SignedInView { @Environment(AuthService.self) private var authService -} + @State private var showDeleteConfirmation = false + @State private var showEmailVerificationSent = false -extension SignedInView: View { - private var isShowingPasswordPrompt: Binding { - Binding( - get: { authService.passwordPrompt.isPromptingPassword }, - set: { authService.passwordPrompt.isPromptingPassword = $0 } - ) + private func sendEmailVerification() async { + do { + try await authService.sendEmailVerification() + showEmailVerificationSent = true + } catch { + // Error already displayed via modal by AuthService + } } +} +extension SignedInView: View { public var body: some View { - if authService.authView == .updatePassword { - UpdatePasswordView() - } else { - VStack { - Text(authService.string.signedInTitle) - .font(.largeTitle) - .fontWeight(.bold) - .padding() - .accessibilityIdentifier("signed-in-text") - Text(authService.string.accountSettingsEmailLabel) - Text("\(authService.currentUser?.email ?? "Unknown")") - - if authService.currentUser?.isEmailVerified == false { - VerifyEmailView() - } - Divider() - Button(authService.string.updatePasswordButtonLabel) { - authService.authView = .updatePassword - } - Divider() - Button(authService.string.signOutButtonLabel) { + @Bindable var passwordPrompt = authService.passwordPrompt + VStack { + Text(authService.string.signedInTitle) + .font(.largeTitle) + .fontWeight(.bold) + .padding() + .accessibilityIdentifier("signed-in-text") + Text( + "\(authService.currentUser?.email ?? authService.currentUser?.displayName ?? "Unknown")" + ) + if authService.currentUser?.isEmailVerified == false { + Button { Task { - do { - try await authService.signOut() - } catch {} + await sendEmailVerification() } - }.accessibilityIdentifier("sign-out-button") - Divider() - Button(authService.string.deleteAccountButtonLabel) { + } label: { + Text(authService.string.sendEmailVerificationButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("verify-email-button") + } + Button { + authService.navigator.push(.updatePassword) + } label: { + Text(authService.string.updatePasswordButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("update-password-button") + + Button { + authService.navigator.push(.mfaManagement) + } label: { + Text("Manage Two-Factor Authentication") + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("mfa-management-button") + + Button { + showDeleteConfirmation = true + } label: { + Text(authService.string.deleteAccountButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("delete-account-button") + + Button { + Task { + try? await authService.signOut() + } + } label: { + Text(authService.string.signOutButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("sign-out-button") + } + .safeAreaPadding() + .sheet(isPresented: $showDeleteConfirmation) { + DeleteAccountConfirmationSheet( + onConfirm: { + showDeleteConfirmation = false Task { - do { - try await authService.deleteUser() - } catch {} + try? await authService.deleteUser() } + }, + onCancel: { + showDeleteConfirmation = false + } + ) + .presentationDetents([.medium]) + } + .sheet(isPresented: $passwordPrompt.isPromptingPassword) { + PasswordPromptSheet(coordinator: authService.passwordPrompt) + } + .sheet(isPresented: $showEmailVerificationSent) { + VStack(spacing: 24) { + Text(authService.string.verifyEmailSheetMessage) + .font(.headline) + Button { + showEmailVerificationSent = false + } label: { + Text(authService.string.okButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .safeAreaPadding() + .presentationDetents([.medium]) + } + } +} + +private struct DeleteAccountConfirmationSheet: View { + @Environment(AuthService.self) private var authService + let onConfirm: () -> Void + let onCancel: () -> Void + + var body: some View { + VStack(spacing: 24) { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 60)) + .foregroundColor(.red) + + Text("Delete Account?") + .font(.title) + .fontWeight(.bold) + + Text( + "This action cannot be undone. All your data will be permanently deleted. You may need to reauthenticate to complete this action." + ) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + VStack(spacing: 12) { + Button { + onConfirm() + } label: { + Text("Delete Account") + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.red) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("confirm-delete-button") + + Button { + onCancel() + } label: { + Text("Cancel") + .padding(.vertical, 8) + .frame(maxWidth: .infinity) } - Text(authService.errorMessage).foregroundColor(.red) - }.sheet(isPresented: isShowingPasswordPrompt) { - PasswordPromptSheet(coordinator: authService.passwordPrompt) + .buttonStyle(.bordered) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("cancel-delete-button") } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .safeAreaPadding() } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePassword.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift similarity index 59% rename from FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePassword.swift rename to FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift index c1a43d422e..b2e7921797 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePassword.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift @@ -18,7 +18,7 @@ // // Created by Russell Wheatley on 24/04/2025. // - +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @@ -40,45 +40,39 @@ public struct UpdatePasswordView { } extension UpdatePasswordView: View { - private var isShowingPasswordPrompt: Binding { - Binding( - get: { authService.passwordPrompt.isPromptingPassword }, - set: { authService.passwordPrompt.isPromptingPassword = $0 } - ) - } - public var body: some View { - VStack { - LabeledContent { - SecureField(authService.string.passwordInputLabel, text: $password) - .focused($focus, equals: .password) - .submitLabel(.go) - } label: { - Image(systemName: "lock") - } - .padding(.vertical, 6) - .background(Divider(), alignment: .bottom) - .padding(.bottom, 8) - - Divider() - - LabeledContent { - SecureField(authService.string.confirmPasswordInputLabel, text: $confirmPassword) - .focused($focus, equals: .confirmPassword) - .submitLabel(.go) - } label: { - Image(systemName: "lock") - } - .padding(.vertical, 6) - .background(Divider(), alignment: .bottom) - .padding(.bottom, 8) + @Bindable var passwordPrompt = authService.passwordPrompt + VStack(spacing: 24) { + AuthTextField( + text: $password, + localizedTitle: "Type new password", + prompt: authService.string.passwordInputLabel, + contentType: .password, + sensitive: true, + leading: { + Image(systemName: "lock") + } + ) + .submitLabel(.go) + .focused($focus, equals: .password) - Divider() + AuthTextField( + text: $confirmPassword, + localizedTitle: "Retype new password", + prompt: authService.string.confirmPasswordInputLabel, + contentType: .password, + sensitive: true, + leading: { + Image(systemName: "lock") + } + ) + .submitLabel(.go) + .focused($focus, equals: .confirmPassword) Button(action: { Task { try await authService.updatePassword(to: confirmPassword) - authService.authView = .authPicker + authService.navigator.clear() } }, label: { Text(authService.string.updatePasswordButtonLabel) @@ -90,7 +84,11 @@ extension UpdatePasswordView: View { .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .buttonStyle(.borderedProminent) - }.sheet(isPresented: isShowingPasswordPrompt) { + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .safeAreaPadding() + .navigationTitle(authService.string.passwordRecoveryTitle) + .sheet(isPresented: $passwordPrompt.isPromptingPassword) { PasswordPromptSheet(coordinator: authService.passwordPrompt) } } @@ -98,6 +96,8 @@ extension UpdatePasswordView: View { #Preview { FirebaseOptions.dummyConfigurationForPreview() - return UpdatePasswordView() - .environment(AuthService()) + return NavigationStack { + UpdatePasswordView() + .environment(AuthService()) + } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/VerifyEmailView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/VerifyEmailView.swift deleted file mode 100644 index ac2c2f8be5..0000000000 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/VerifyEmailView.swift +++ /dev/null @@ -1,63 +0,0 @@ -// 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 FirebaseCore -import SwiftUI - -public struct VerifyEmailView { - @Environment(AuthService.self) private var authService - @State private var showModal = false - - private func sendEmailVerification() async { - do { - try await authService.sendEmailVerification() - showModal = true - } catch {} - } -} - -extension VerifyEmailView: View { - public var body: some View { - VStack { - Button(action: { - Task { - await sendEmailVerification() - } - }) { - Text(authService.string.sendEmailVerificationButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .padding([.top, .bottom, .horizontal], 8) - .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) - }.sheet(isPresented: $showModal) { - VStack { - Text(authService.string.verifyEmailSheetMessage) - .font(.headline) - Button(authService.string.okButtonLabel) { - showModal = false - } - .padding() - } - .padding() - } - } -} - -#Preview { - FirebaseOptions.dummyConfigurationForPreview() - return VerifyEmailView() - .environment(AuthService()) -} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentUnitTests.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentUnitTests.swift new file mode 100644 index 0000000000..c4564dc308 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentUnitTests.swift @@ -0,0 +1,93 @@ +// 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. + +// +// MFAEnrollmentUnitTests.swift +// FirebaseAuthSwiftUITests +// +// Unit tests for MFA enrollment data structures +// + +import FirebaseAuth +import FirebaseAuthSwiftUI +import Foundation +import Testing + +// MARK: - TOTPEnrollmentInfo Tests + +@Suite("TOTPEnrollmentInfo Tests") +struct TOTPEnrollmentInfoTests { + @Test("Initialization with shared secret key") + func testInitializationWithSharedSecretKey() { + let validSecrets = [ + "JBSWY3DPEHPK3PXP", + "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", + "MFRGG43FMZQW4ZY=", + ] + + for secret in validSecrets { + let totpInfo = TOTPEnrollmentInfo(sharedSecretKey: secret) + #expect(totpInfo.sharedSecretKey == secret) + #expect(totpInfo.verificationStatus == .pending) + #expect(totpInfo.qrCodeURL == nil) + #expect(totpInfo.accountName == nil) + #expect(totpInfo.issuer == nil) + } + } + + @Test("Initialization with all parameters") + func testInitializationWithAllParameters() throws { + let totpInfo = TOTPEnrollmentInfo( + sharedSecretKey: "JBSWY3DPEHPK3PXP", + qrCodeURL: URL( + string: "otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example" + ), + accountName: "alice@example.com", + issuer: "Example", + verificationStatus: .verified + ) + + #expect(totpInfo.sharedSecretKey == "JBSWY3DPEHPK3PXP") + #expect(totpInfo.accountName == "alice@example.com") + #expect(totpInfo.issuer == "Example") + #expect(totpInfo.verificationStatus == .verified) + + let qrURL = try #require(totpInfo.qrCodeURL) + #expect(qrURL.scheme == "otpauth") + #expect(qrURL.host == "totp") + #expect(qrURL.query?.contains("secret=JBSWY3DPEHPK3PXP") == true) + #expect(qrURL.query?.contains("issuer=Example") == true) + } + + @Test("Verification status transitions") + func testVerificationStatusTransitions() { + // Default status is pending + var totpInfo = TOTPEnrollmentInfo(sharedSecretKey: "JBSWY3DPEHPK3PXP") + #expect(totpInfo.verificationStatus == .pending) + + // Verified status + totpInfo = TOTPEnrollmentInfo( + sharedSecretKey: "JBSWY3DPEHPK3PXP", + verificationStatus: .verified + ) + #expect(totpInfo.verificationStatus == .verified) + + // Failed status + totpInfo = TOTPEnrollmentInfo( + sharedSecretKey: "JBSWY3DPEHPK3PXP", + verificationStatus: .failed + ) + #expect(totpInfo.verificationStatus == .failed) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Assets.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Assets.swift new file mode 100644 index 0000000000..3c55153757 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Assets.swift @@ -0,0 +1,20 @@ +// 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 + +@MainActor +public struct Assets { + public static var firebaseAuthLogo: ImageResource = .firebaseAuthLogo +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift new file mode 100644 index 0000000000..f8df18d14d --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthProviderButton.swift @@ -0,0 +1,81 @@ +// 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 styled button component for authentication providers +/// Used by all provider packages to maintain consistent UI +public struct AuthProviderButton: View { + let label: String + let style: ProviderStyle + let action: () -> Void + var enabled: Bool + var accessibilityId: String? + + public init(label: String, + style: ProviderStyle, + enabled: Bool = true, + accessibilityId: String? = nil, + action: @escaping () -> Void) { + self.label = label + self.style = style + self.enabled = enabled + self.accessibilityId = accessibilityId + self.action = action + } + + public var body: some View { + Button(action: action) { + HStack(spacing: 12) { + if let icon = style.icon { + providerIcon(for: icon, tint: style.iconTint) + } + Text(label) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(style.contentColor) + } + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.borderedProminent) + .tint(style.backgroundColor) + .shadow( + color: Color.black.opacity(0.12), + radius: Double(style.elevation), + x: 0, + y: style.elevation > 0 ? 1 : 0 + ) + .disabled(!enabled) + .accessibilityIdentifier(accessibilityId ?? "auth-provider-button") + } + + @ViewBuilder + private func providerIcon(for image: Image, tint: Color?) -> some View { + if let tint { + image + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundStyle(tint) + } else { + image + .renderingMode(.original) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift new file mode 100644 index 0000000000..9c3247c6c7 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/AuthTextField.swift @@ -0,0 +1,171 @@ +// 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 + +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 + + @Binding var text: String + let localizedTitle: String + let prompt: String + var textAlignment: TextAlignment = .leading + var keyboardType: UIKeyboardType = .default + var contentType: UITextContentType? = nil + var isSecureTextField: Bool = false + var validations: [FieldValidation] = [] + var formState: ((Bool) -> Void)? = nil + var onSubmit: ((String) -> Void)? = nil + var onChange: ((String) -> Void)? = nil + private let leading: () -> Leading? + + public init(text: Binding, + localizedTitle: String, + prompt: String, + textAlignment: TextAlignment = .leading, + keyboardType: UIKeyboardType = .default, + contentType: UITextContentType? = nil, + sensitive: Bool = false, + validations: [FieldValidation] = [], + formState: ((Bool) -> Void)? = nil, + onSubmit: ((String) -> Void)? = nil, + onChange: ((String) -> Void)? = nil, + @ViewBuilder leading: @escaping () -> Leading? = { EmptyView() }) { + _text = text + self.localizedTitle = localizedTitle + self.prompt = prompt + self.textAlignment = textAlignment + self.keyboardType = keyboardType + self.contentType = contentType + isSecureTextField = sensitive + self.validations = validations + self.formState = formState + self.onSubmit = onSubmit + self.onChange = onChange + self.leading = leading + } + + var allRequirementsMet: Bool { + validations.allSatisfy { $0.valid == true } + } + + public var body: some View { + VStack(alignment: .leading) { + Text(localizedTitle) + HStack(spacing: 8) { + leading() + Group { + if isSecureTextField { + ZStack(alignment: .trailing) { + SecureField(localizedTitle, text: $text, prompt: Text(prompt)) + .opacity(obscured ? 1 : 0) + .focused($isFocused) + .frame(height: 24) + TextField(localizedTitle, text: $text, prompt: Text(prompt)) + .opacity(obscured ? 0 : 1) + .focused($isFocused) + .frame(height: 24) + if !text.isEmpty { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + obscured.toggle() + } + // Reapply focus after toggling + DispatchQueue.main.async { + isFocused = true + } + } label: { + Image(systemName: obscured ? "eye" : "eye.slash") + } + .buttonStyle(.plain) + } + } + } else { + TextField( + localizedTitle, + text: $text, + prompt: Text(prompt) + ) + .frame(height: 24) + } + } + } + .frame(maxWidth: .infinity) + .keyboardType(keyboardType) + .textContentType(contentType) + .autocapitalization(.none) + .disableAutocorrection(true) + .focused($isFocused) + .onSubmit { + onSubmit?(text) + } + .onChange(of: text) { _, newValue in + onChange?(newValue) + } + .multilineTextAlignment(textAlignment) + .textFieldStyle(.plain) + .padding(.vertical, 12) + .padding(.horizontal, 12) + .background { + RoundedRectangle(cornerRadius: 8) + .fill(Color.accentColor.opacity(0.05)) + .strokeBorder(lineWidth: isFocused ? 3 : 1) + .foregroundStyle(isFocused ? Color.accentColor : Color(.systemFill)) + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation { + isFocused = true + } + } + if !validations.isEmpty { + 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) + } + } + } + .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/CountrySelector.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift new file mode 100644 index 0000000000..49d94e9fe1 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/CountrySelector.swift @@ -0,0 +1,108 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SwiftUI + +public struct CountryData: Equatable { + public let name: String + public let dialCode: String + public let code: String + + public init(name: String, dialCode: String, code: String) { + self.name = name + self.dialCode = dialCode + self.code = code + } + + public var flag: String { + let base: UInt32 = 127_397 + var s = "" + for v in code.unicodeScalars { + s.unicodeScalars.append(UnicodeScalar(base + v.value)!) + } + return String(s) + } + + @MainActor public static let `default` = CountryData( + name: "United States", + dialCode: "+1", + code: "US" + ) +} + +public struct CountrySelector: View { + @Binding var selectedCountry: CountryData + var enabled: Bool = true + var allowedCountries: Set? = nil + + public init(selectedCountry: Binding, + enabled: Bool = true, + allowedCountries: Set? = nil) { + _selectedCountry = selectedCountry + self.enabled = enabled + self.allowedCountries = allowedCountries + } + + // Common countries list + private let allCountries: [CountryData] = [ + CountryData(name: "United States", dialCode: "+1", code: "US"), + CountryData(name: "United Kingdom", dialCode: "+44", code: "GB"), + CountryData(name: "Canada", dialCode: "+1", code: "CA"), + CountryData(name: "Australia", dialCode: "+61", code: "AU"), + CountryData(name: "Germany", dialCode: "+49", code: "DE"), + CountryData(name: "France", dialCode: "+33", code: "FR"), + CountryData(name: "India", dialCode: "+91", code: "IN"), + CountryData(name: "Nigeria", dialCode: "+234", code: "NG"), + CountryData(name: "South Africa", dialCode: "+27", code: "ZA"), + CountryData(name: "Japan", dialCode: "+81", code: "JP"), + CountryData(name: "China", dialCode: "+86", code: "CN"), + CountryData(name: "Brazil", dialCode: "+55", code: "BR"), + CountryData(name: "Mexico", dialCode: "+52", code: "MX"), + CountryData(name: "Spain", dialCode: "+34", code: "ES"), + CountryData(name: "Italy", dialCode: "+39", code: "IT"), + ] + + private var filteredCountries: [CountryData] { + if let allowedCountries = allowedCountries { + return allCountries.filter { allowedCountries.contains($0.code) } + } + return allCountries + } + + public var body: some View { + Menu { + ForEach(filteredCountries, id: \.code) { country in + Button { + selectedCountry = country + } label: { + Text("\(country.flag) \(country.name) (\(country.dialCode))") + } + .accessibilityIdentifier("country-option-\(country.code)") + } + } label: { + HStack(spacing: 4) { + Text(selectedCountry.flag) + .font(.title3) + Text(selectedCountry.dialCode) + .font(.body) + .foregroundStyle(.primary) + Image(systemName: "chevron.down") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .accessibilityIdentifier("country-selector") + .disabled(!enabled) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift new file mode 100644 index 0000000000..fc99208c02 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Components/VerificationCodeInputField.swift @@ -0,0 +1,551 @@ +// 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 +import UIKit + +public struct VerificationCodeInputField: View { + public init(code: Binding, + codeLength: Int = 6, + isError: Bool = false, + errorMessage: String? = nil, + onCodeComplete: @escaping (String) -> Void = { _ in }, + onCodeChange: @escaping (String) -> Void = { _ in }) { + _code = code + self.codeLength = codeLength + self.isError = isError + self.errorMessage = errorMessage + self.onCodeComplete = onCodeComplete + self.onCodeChange = onCodeChange + _digitFields = State(initialValue: Array(repeating: "", count: codeLength)) + } + + @Binding var code: String + let codeLength: Int + let isError: Bool + let errorMessage: String? + let onCodeComplete: (String) -> Void + let onCodeChange: (String) -> Void + + @State private var digitFields: [String] = [] + @State private var focusedIndex: Int? = nil + @State private var pendingInternalCodeUpdates = 0 + + public var body: some View { + VStack(spacing: 8) { + HStack(spacing: 8) { + ForEach(0 ..< codeLength, id: \.self) { index in + SingleDigitField( + digit: $digitFields[index], + isError: isError, + isFocused: focusedIndex == index, + maxDigits: codeLength - index, + position: index + 1, + totalDigits: codeLength, + onDigitChanged: { newDigit in + handleDigitChanged(at: index, newDigit: newDigit) + }, + onBackspace: { + handleBackspace(at: index) + }, + onFocusChanged: { isFocused in + DispatchQueue.main.async { + if isFocused { + if focusedIndex != index { + withAnimation(.easeInOut(duration: 0.2)) { + focusedIndex = index + } + } + } else if focusedIndex == index { + withAnimation(.easeInOut(duration: 0.2)) { + focusedIndex = nil + } + } + } + } + ) + } + } + + if isError, let errorMessage = errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .onAppear { + // Initialize digit fields from the code binding + updateDigitFieldsFromCode(shouldUpdateFocus: true, forceFocus: true) + } + .onChange(of: code) { _, _ in + if pendingInternalCodeUpdates > 0 { + pendingInternalCodeUpdates -= 1 + return + } + updateDigitFieldsFromCode(shouldUpdateFocus: true) + } + } + + private func updateDigitFieldsFromCode(shouldUpdateFocus: Bool, forceFocus: Bool = false) { + let sanitized = code.filter { $0.isNumber } + let truncated = String(sanitized.prefix(codeLength)) + var newFields = Array(repeating: "", count: codeLength) + + for (offset, character) in truncated.enumerated() { + newFields[offset] = String(character) + } + + let fieldsChanged = newFields != digitFields + if fieldsChanged { + digitFields = newFields + } + + if code != truncated { + commitCodeChange(truncated) + } + + if shouldUpdateFocus && (fieldsChanged || forceFocus) { + let newFocus = truncated.count < codeLength ? truncated.count : nil + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.2)) { + focusedIndex = newFocus + } + } + } + + if fieldsChanged && truncated.count == codeLength { + DispatchQueue.main.async { + onCodeComplete(truncated) + } + } + } + + private func commitCodeChange(_ newCode: String) { + if code != newCode { + pendingInternalCodeUpdates += 1 + code = newCode + } + } + + private func handleDigitChanged(at index: Int, newDigit: String) { + let sanitized = newDigit.filter { $0.isNumber } + + guard !sanitized.isEmpty else { + processSingleDigitInput(at: index, digit: "") + return + } + + let firstDigit = String(sanitized.prefix(1)) + processSingleDigitInput(at: index, digit: firstDigit) + + let remainder = String(sanitized.dropFirst()) + let availableSlots = max(codeLength - (index + 1), 0) + if availableSlots > 0 { + let trimmedRemainder = String(remainder.prefix(availableSlots)) + if !trimmedRemainder.isEmpty { + applyBulkInput(startingAt: index + 1, digits: trimmedRemainder) + } + } + } + + private func processSingleDigitInput(at index: Int, digit: String) { + if digitFields[index] != digit { + digitFields[index] = digit + } + + let newCode = digitFields.joined() + commitCodeChange(newCode) + onCodeChange(newCode) + + if !digit.isEmpty, + let nextIndex = findNextEmptyField(startingFrom: index) { + DispatchQueue.main.async { + if focusedIndex != nextIndex { + withAnimation(.easeInOut(duration: 0.2)) { + focusedIndex = nextIndex + } + } + } + } + + if newCode.count == codeLength { + DispatchQueue.main.async { + onCodeComplete(newCode) + } + } + } + + private func handleBackspace(at index: Int) { + // If current field is empty, move to previous field and clear it + if digitFields[index].isEmpty && index > 0 { + digitFields[index - 1] = "" + DispatchQueue.main.async { + let previousIndex = index - 1 + if focusedIndex != previousIndex { + withAnimation(.easeInOut(duration: 0.2)) { + focusedIndex = previousIndex + } + } + } + } else { + // Clear current field + digitFields[index] = "" + } + + // Update the main code string + let newCode = digitFields.joined() + commitCodeChange(newCode) + onCodeChange(newCode) + } + + private func applyBulkInput(startingAt index: Int, digits: String) { + guard !digits.isEmpty, index < codeLength else { return } + + var updatedFields = digitFields + var currentIndex = index + + for digit in digits where currentIndex < codeLength { + updatedFields[currentIndex] = String(digit) + currentIndex += 1 + } + + if digitFields != updatedFields { + digitFields = updatedFields + } + + let newCode = updatedFields.joined() + commitCodeChange(newCode) + onCodeChange(newCode) + + if newCode.count == codeLength { + DispatchQueue.main.async { + onCodeComplete(newCode) + } + } else { + let clampedIndex = max(min(currentIndex - 1, codeLength - 1), 0) + if let nextIndex = findNextEmptyField(startingFrom: clampedIndex) { + DispatchQueue.main.async { + if focusedIndex != nextIndex { + withAnimation(.easeInOut(duration: 0.2)) { + focusedIndex = nextIndex + } + } + } + } + } + } + + private func findNextEmptyField(startingFrom index: Int) -> Int? { + // Look for the next empty field after the current index + for i in (index + 1) ..< codeLength { + if digitFields[i].isEmpty { + return i + } + } + // If no empty field found after current index, look from the beginning + for i in 0 ..< index { + if digitFields[i].isEmpty { + return i + } + } + return nil + } +} + +private struct SingleDigitField: View { + @Binding var digit: String + let isError: Bool + let isFocused: Bool + let maxDigits: Int + let position: Int + let totalDigits: Int + let onDigitChanged: (String) -> Void + let onBackspace: () -> Void + let onFocusChanged: (Bool) -> Void + + private var borderWidth: CGFloat { + if isError { return 2 } + if isFocused || !digit.isEmpty { return 3 } + return 1 + } + + private var borderColor: Color { + if isError { return .red } + if isFocused || !digit.isEmpty { return .accentColor } + return Color(.systemFill) + } + + var body: some View { + BackspaceAwareTextField( + text: $digit, + isFirstResponder: isFocused, + onDeleteBackwardWhenEmpty: { + if digit.isEmpty { + onBackspace() + } else { + digit = "" + } + }, + onFocusChanged: { isFocused in + onFocusChanged(isFocused) + }, + maxCharacters: maxDigits, + configuration: { textField in + textField.font = .systemFont(ofSize: 24, weight: .medium) + textField.textAlignment = .center + textField.keyboardType = .numberPad + textField.textContentType = .oneTimeCode + textField.autocapitalizationType = .none + textField.autocorrectionType = .no + }, + onTextChange: { newValue in + onDigitChanged(newValue) + } + ) + .frame(width: 48, height: 48) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.accentColor.opacity(0.05)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(borderColor, lineWidth: borderWidth) + ) + ) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Digit \(position) of \(totalDigits)") + .accessibilityValue(digit.isEmpty ? "Empty" : digit) + .accessibilityHint("Enter verification code digit") + .animation(.easeInOut(duration: 0.2), value: isFocused) + .animation(.easeInOut(duration: 0.2), value: digit) + } +} + +private struct BackspaceAwareTextField: UIViewRepresentable { + @Binding var text: String + var isFirstResponder: Bool + let onDeleteBackwardWhenEmpty: () -> Void + let onFocusChanged: (Bool) -> Void + let maxCharacters: Int + let configuration: (UITextField) -> Void + let onTextChange: (String) -> Void + + func makeUIView(context: Context) -> BackspaceUITextField { + context.coordinator.parent = self + let textField = BackspaceUITextField() + textField.delegate = context.coordinator + textField.addTarget( + context.coordinator, + action: #selector(Coordinator.editingChanged(_:)), + for: .editingChanged + ) + configuration(textField) + textField.onDeleteBackward = { [weak textField] in + guard let textField else { return } + if (textField.text ?? "").isEmpty { + onDeleteBackwardWhenEmpty() + } + } + return textField + } + + func updateUIView(_ uiView: BackspaceUITextField, context: Context) { + context.coordinator.parent = self + if uiView.text != text { + uiView.text = text + } + + uiView.onDeleteBackward = { [weak uiView] in + guard let uiView else { return } + if (uiView.text ?? "").isEmpty { + onDeleteBackwardWhenEmpty() + } + } + + if isFirstResponder { + if !context.coordinator.isFirstResponder { + context.coordinator.isFirstResponder = true + DispatchQueue.main.async { [weak uiView] in + guard let uiView, !uiView.isFirstResponder else { return } + uiView.becomeFirstResponder() + } + } + } else if context.coordinator.isFirstResponder { + context.coordinator.isFirstResponder = false + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + final class Coordinator: NSObject, UITextFieldDelegate { + var parent: BackspaceAwareTextField + var isFirstResponder = false + + init(parent: BackspaceAwareTextField) { + self.parent = parent + } + + @objc func editingChanged(_ sender: UITextField) { + let updatedText = sender.text ?? "" + parent.text = updatedText + parent.onTextChange(updatedText) + } + + func textFieldDidBeginEditing(_ textField: UITextField) { + isFirstResponder = true + animateFocusChange(for: textField, focused: true) + parent.onFocusChanged(true) + } + + func textFieldDidEndEditing(_ textField: UITextField) { + isFirstResponder = false + animateFocusChange(for: textField, focused: false) + parent.onFocusChanged(false) + } + + private func animateFocusChange(for textField: UITextField, focused: Bool) { + let targetTransform: CGAffineTransform = focused ? CGAffineTransform(scaleX: 1.05, y: 1.05) : + .identity + UIView.animate( + withDuration: 0.2, + delay: 0, + options: [.curveEaseInOut, .allowUserInteraction] + ) { + textField.transform = targetTransform + } + } + + func textField(_ textField: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String) -> Bool { + if string.isEmpty { + return true + } + + let digitsOnly = string.filter { $0.isNumber } + guard !digitsOnly.isEmpty else { + return false + } + + let currentText = textField.text ?? "" + let nsCurrent = currentText as NSString + + if digitsOnly.count > 1 || string.count > 1 { + let limit = max(parent.maxCharacters, 1) + let truncated = String(digitsOnly.prefix(limit)) + let proposed = nsCurrent.replacingCharacters(in: range, with: truncated) + parent.onTextChange(String(proposed.prefix(limit))) + return false + } + + let updated = nsCurrent.replacingCharacters(in: range, with: digitsOnly) + return updated.count <= 1 + } + } +} + +private final class BackspaceUITextField: UITextField { + var onDeleteBackward: (() -> Void)? + + override func deleteBackward() { + let wasEmpty = (text ?? "").isEmpty + super.deleteBackward() + if wasEmpty { + onDeleteBackward?() + } + } +} + +// MARK: - Preview + +#Preview("Normal State") { + @Previewable @State var code = "" + + return VStack(spacing: 32) { + Text("Enter Verification Code") + .font(.title2) + .fontWeight(.semibold) + + VerificationCodeInputField( + code: $code, + onCodeComplete: { completedCode in + print("Code completed: \(completedCode)") + }, + onCodeChange: { newCode in + print("Code changed: \(newCode)") + } + ) + + Text("Current code: \(code)") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() +} + +#Preview("Error State") { + @Previewable @State var code = "12345" + + return VStack(spacing: 32) { + Text("Enter Verification Code") + .font(.title2) + .fontWeight(.semibold) + + VerificationCodeInputField( + code: $code, + isError: true, + errorMessage: "Invalid verification code", + onCodeComplete: { completedCode in + print("Code completed: \(completedCode)") + }, + onCodeChange: { newCode in + print("Code changed: \(newCode)") + } + ) + + Text("Current code: \(code)") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() +} + +#Preview("Custom Length") { + @Previewable @State var code = "" + + return VStack(spacing: 32) { + Text("Enter 4-Digit Code") + .font(.title2) + .fontWeight(.semibold) + + VerificationCodeInputField( + code: $code, + codeLength: 4, + onCodeComplete: { completedCode in + print("Code completed: \(completedCode)") + }, + onCodeChange: { newCode in + print("Code changed: \(newCode)") + } + ) + + Text("Current code: \(code)") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Extensions/Color+Hex.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Extensions/Color+Hex.swift new file mode 100644 index 0000000000..1066edba52 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Extensions/Color+Hex.swift @@ -0,0 +1,25 @@ +// 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 + +extension Color { + init(hex: UInt, alpha: Double = 1.0) { + let red = Double((hex >> 16) & 0xFF) / 255.0 + let green = Double((hex >> 8) & 0xFF) / 255.0 + let blue = Double(hex & 0xFF) / 255.0 + + self.init(.sRGB, red: red, green: green, blue: blue, opacity: alpha) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000000..eb87897008 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..2305880107 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Contents.json new file mode 100644 index 0000000000..ec9b5e4a36 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Product_Logomark_Authentication_Full_Color 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Product_Logomark_Authentication_Full_Color 1 (1).png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Product_Logomark_Authentication_Full_Color 1 (2).png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Product_Logomark_Authentication_Full_Color 1 (1).png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Product_Logomark_Authentication_Full_Color 1 (1).png new file mode 100644 index 0000000000..fecbcb6dd4 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Product_Logomark_Authentication_Full_Color 1 (1).png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Product_Logomark_Authentication_Full_Color 1 (2).png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Product_Logomark_Authentication_Full_Color 1 (2).png new file mode 100644 index 0000000000..9df93f97a3 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Product_Logomark_Authentication_Full_Color 1 (2).png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Product_Logomark_Authentication_Full_Color 1.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Product_Logomark_Authentication_Full_Color 1.png new file mode 100644 index 0000000000..bc9af3cc0c Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/firebase-auth-logo.imageset/Product_Logomark_Authentication_Full_Color 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/Contents.json new file mode 100644 index 0000000000..482a49b90f --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui_ic_anonymous_white_24dp 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui_ic_anonymous_white_24dp 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui_ic_anonymous_white_24dp.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp 1.png new file mode 100644 index 0000000000..4867274485 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp 2.png new file mode 100644 index 0000000000..5c2f2bcd90 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp.png new file mode 100644 index 0000000000..9d57c10f7e Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-anonymous.imageset/fui_ic_anonymous_white_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/Contents.json new file mode 100644 index 0000000000..b8005dda54 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui_ic_apple_white_24dp 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui_ic_apple_white_24dp 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui_ic_apple_white_24dp.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp 1.png new file mode 100644 index 0000000000..d251bbd78f Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp 2.png new file mode 100644 index 0000000000..7c239197b3 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp.png new file mode 100644 index 0000000000..0914e18323 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-apple.imageset/fui_ic_apple_white_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/Contents.json new file mode 100644 index 0000000000..daff137156 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui_ic_facebook_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui_ic_facebook_icon_x2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui_ic_facebook_icon_x3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_icon.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_icon.png new file mode 100644 index 0000000000..b8562f4939 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_icon.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_icon_x2.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_icon_x2.png new file mode 100644 index 0000000000..deb223d3f1 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_icon_x2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_icon_x3.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_icon_x3.png new file mode 100644 index 0000000000..054ffec0ad Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-facebook.imageset/fui_ic_facebook_icon_x3.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/Contents.json new file mode 100644 index 0000000000..6acf81f95a --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui_ic_github_white_24dp 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui_ic_github_white_24dp 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui_ic_github_white_24dp.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp 1.png new file mode 100644 index 0000000000..bad7f150f9 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp 2.png new file mode 100644 index 0000000000..aa84b536ca Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp.png new file mode 100644 index 0000000000..437f627122 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-github.imageset/fui_ic_github_white_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/Contents.json new file mode 100644 index 0000000000..f6ede1b0b3 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui_ic_googleg_color_24dp 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui_ic_googleg_color_24dp 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui_ic_googleg_color_24dp.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp 1.png new file mode 100644 index 0000000000..c9f49bd31f Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp 2.png new file mode 100644 index 0000000000..a3c7bf97ca Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp.png new file mode 100644 index 0000000000..9df17f75fe Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-googleg.imageset/fui_ic_googleg_color_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/Contents.json new file mode 100644 index 0000000000..2401fa19fa --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui_ic_mail_white_24dp 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui_ic_mail_white_24dp 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui_ic_mail_white_24dp.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp 1.png new file mode 100644 index 0000000000..b8f42d5d78 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp 2.png new file mode 100644 index 0000000000..937721e2eb Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp.png new file mode 100644 index 0000000000..273756411a Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-mail.imageset/fui_ic_mail_white_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/Contents.json new file mode 100644 index 0000000000..123e877f0a --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui_ic_microsoft_24dp 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui_ic_microsoft_24dp 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui_ic_microsoft_24dp.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp 1.png new file mode 100644 index 0000000000..b43f424a5d Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp 2.png new file mode 100644 index 0000000000..5455ead2cf Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp.png new file mode 100644 index 0000000000..98ca3614c5 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-microsoft.imageset/fui_ic_microsoft_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/Contents.json new file mode 100644 index 0000000000..14af6b8003 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui_ic_phone_white_24dp 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui_ic_phone_white_24dp 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui_ic_phone_white_24dp.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp 1.png new file mode 100644 index 0000000000..e040bdf1a9 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp 2.png new file mode 100644 index 0000000000..70579d4aa6 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp.png new file mode 100644 index 0000000000..27a6b5438c Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-phone.imageset/fui_ic_phone_white_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/Contents.json new file mode 100644 index 0000000000..24174d49ac --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui_ic_twitter_x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui_ic_twitter_x_x2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui_ic_twitter_x_x3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/fui_ic_twitter_x.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/fui_ic_twitter_x.png new file mode 100644 index 0000000000..803b7a5e68 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/fui_ic_twitter_x.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/fui_ic_twitter_x_x2.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/fui_ic_twitter_x_x2.png new file mode 100644 index 0000000000..dd26fab7ba Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/fui_ic_twitter_x_x2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/fui_ic_twitter_x_x3.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/fui_ic_twitter_x_x3.png new file mode 100644 index 0000000000..dc5ad74564 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-twitter-x.imageset/fui_ic_twitter_x_x3.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/Contents.json b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/Contents.json new file mode 100644 index 0000000000..9ae684f1b1 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui_ic_yahoo_24dp 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui_ic_yahoo_24dp 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui_ic_yahoo_24dp.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp 1.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp 1.png new file mode 100644 index 0000000000..0b733b01ae Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp 1.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp 2.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp 2.png new file mode 100644 index 0000000000..be4fe60ce5 Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp 2.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp.png b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp.png new file mode 100644 index 0000000000..9f6b1ec58b Binary files /dev/null and b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Resources/Assets.xcassets/fui-ic-yahoo.imageset/fui_ic_yahoo_24dp.png differ diff --git a/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/ProviderStyle.swift b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/ProviderStyle.swift new file mode 100644 index 0000000000..a26a42b9c3 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthUIComponents/Sources/Theme/ProviderStyle.swift @@ -0,0 +1,125 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SwiftUI + +/// Styling configuration for authentication provider buttons +public struct ProviderStyle: Sendable { + public init(icon: Image? = nil, + backgroundColor: Color, + contentColor: Color, + iconTint: Color? = nil, + elevation: CGFloat = 2) { + self.icon = icon + self.backgroundColor = backgroundColor + self.contentColor = contentColor + self.iconTint = iconTint + self.elevation = elevation + } + + public let icon: Image? + public let backgroundColor: Color + public let contentColor: Color + public var iconTint: Color? = nil + public let shape: AnyShape = .init(RoundedRectangle(cornerRadius: 4, style: .continuous)) + public let elevation: CGFloat + + public static let empty = ProviderStyle( + icon: nil, + backgroundColor: .white, + contentColor: .black + ) + + // MARK: - Predefined Styles + + public static var google: ProviderStyle { + ProviderStyle( + icon: Image(.fuiIcGoogleg), + backgroundColor: Color(hex: 0xFFFFFF), + contentColor: Color(hex: 0x757575) + ) + } + + public static var facebook: ProviderStyle { + ProviderStyle( + icon: Image(.fuiIcFacebook), + backgroundColor: Color(hex: 0x1877F2), + contentColor: Color(hex: 0xFFFFFF) + ) + } + + public static var twitter: ProviderStyle { + ProviderStyle( + icon: Image(.fuiIcTwitterX), + backgroundColor: Color.black, + contentColor: Color(hex: 0xFFFFFF) + ) + } + + public static var apple: ProviderStyle { + ProviderStyle( + icon: Image(.fuiIcApple), + backgroundColor: Color(hex: 0x000000), + contentColor: Color(hex: 0xFFFFFF) + ) + } + + public static var phone: ProviderStyle { + ProviderStyle( + icon: Image(.fuiIcPhone), + backgroundColor: Color(hex: 0x43C5A5), + contentColor: Color(hex: 0xFFFFFF) + ) + } + + public static var github: ProviderStyle { + ProviderStyle( + icon: Image(.fuiIcGithub), + backgroundColor: Color(hex: 0x24292E), + contentColor: Color(hex: 0xFFFFFF) + ) + } + + public static var microsoft: ProviderStyle { + ProviderStyle( + icon: Image(.fuiIcMicrosoft), + backgroundColor: Color(hex: 0x2F2F2F), + contentColor: Color(hex: 0xFFFFFF) + ) + } + + public static var yahoo: ProviderStyle { + ProviderStyle( + icon: Image(.fuiIcYahoo), + backgroundColor: Color(hex: 0x720E9E), + contentColor: Color(hex: 0xFFFFFF) + ) + } + + public static var anonymous: ProviderStyle { + ProviderStyle( + icon: Image(.fuiIcAnonymous), + backgroundColor: Color(hex: 0xF4B400), + contentColor: Color(hex: 0xFFFFFF) + ) + } + + public static var email: ProviderStyle { + ProviderStyle( + icon: Image(.fuiIcMail), + backgroundColor: Color(hex: 0xD0021B), + contentColor: Color(hex: 0xFFFFFF) + ) + } +} diff --git a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/AccountService+Facebook.swift b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/AccountService+Facebook.swift deleted file mode 100644 index 54f655dff2..0000000000 --- a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/AccountService+Facebook.swift +++ /dev/null @@ -1,61 +0,0 @@ -// 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. - -// -// AccountService+Facebook.swift -// FirebaseUI -// -// Created by Russell Wheatley on 14/05/2025. -// - -@preconcurrency import FirebaseAuth -import FirebaseAuthSwiftUI -import Observation - -protocol FacebookOperationReauthentication { - var facebookProvider: FacebookProviderAuthUI { get } -} - -extension FacebookOperationReauthentication { - @MainActor func reauthenticate() async throws -> AuthenticationToken { - guard let user = Auth.auth().currentUser else { - throw AuthServiceError.reauthenticationRequired("No user currently signed-in") - } - - do { - let credential = try await facebookProvider - .signInWithFacebook(isLimitedLogin: facebookProvider.isLimitedLogin) - try await user.reauthenticate(with: credential) - - return .firebase("") - } catch { - throw AuthServiceError.signInFailed(underlying: error) - } - } -} - -@MainActor -class FacebookDeleteUserOperation: AuthenticatedOperation, - @preconcurrency FacebookOperationReauthentication { - let facebookProvider: FacebookProviderAuthUI - init(facebookProvider: FacebookProviderAuthUI) { - self.facebookProvider = facebookProvider - } - - func callAsFunction(on user: User) async throws { - try await callAsFunction(on: user) { - try await user.delete() - } - } -} diff --git a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/AuthService+Facebook.swift b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/AuthService+Facebook.swift index 5aa8481340..68c201f2cd 100644 --- a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/AuthService+Facebook.swift +++ b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/AuthService+Facebook.swift @@ -23,9 +23,9 @@ import FirebaseAuthSwiftUI public extension AuthService { @discardableResult - func withFacebookSignIn(scopes scopes: [String]? = nil) -> AuthService { - FacebookProviderAuthUI.configureProvider(scopes: scopes) - register(provider: FacebookProviderAuthUI.shared) + func withFacebookSignIn(_ provider: FacebookProviderSwift? = nil) -> AuthService { + registerProvider(providerWithButton: FacebookProviderAuthUI(provider: provider ?? + FacebookProviderSwift())) return self } } diff --git a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/FacebookProviderAuthUI.swift b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/FacebookProviderAuthUI.swift index 2501e50610..9e69109e5f 100644 --- a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/FacebookProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Services/FacebookProviderAuthUI.swift @@ -19,56 +19,22 @@ import FirebaseAuth import FirebaseAuthSwiftUI import SwiftUI -let kFacebookEmailScope = "email" -let kFacebookProfileScope = "public_profile" -let kDefaultFacebookScopes = [kFacebookEmailScope, kFacebookProfileScope] - -public enum FacebookProviderError: Error { - case signInCancelled(String) - case configurationInvalid(String) - case limitedLoginNonce(String) - case accessToken(String) - case authenticationToken(String) -} - -public class FacebookProviderAuthUI: FacebookProviderAuthUIProtocol { - public let id: String = "facebook" +public class FacebookProviderSwift: AuthProviderSwift { let scopes: [String] - let shortName = "Facebook" let providerId = "facebook.com" private let loginManager = LoginManager() private var rawNonce: String? private var shaNonce: String? // Needed for reauthentication - var isLimitedLogin: Bool = true - - @MainActor private static var _shared: FacebookProviderAuthUI = - .init(scopes: kDefaultFacebookScopes) - - @MainActor public static var shared: FacebookProviderAuthUI { - return _shared - } - - @MainActor public static func configureProvider(scopes: [String]? = nil) { - _shared = FacebookProviderAuthUI(scopes: scopes) - } - - private init(scopes: [String]? = nil) { - self.scopes = scopes ?? kDefaultFacebookScopes - } + private var isLimitedLogin: Bool = true - @MainActor public func authButton() -> AnyView { - AnyView(SignInWithFacebookButton()) - } - - public func deleteUser(user: User) async throws { - let operation = FacebookDeleteUserOperation(facebookProvider: self) - try await operation(on: user) + public init(scopes: [String] = ["email", "public_profile"]) { + self.scopes = scopes + isLimitedLogin = ATTrackingManager.trackingAuthorizationStatus != .authorized } - @MainActor public func signInWithFacebook(isLimitedLogin: Bool) async throws -> AuthCredential { + @MainActor public func createAuthCredential() async throws -> AuthCredential { let loginType: LoginTracking = isLimitedLogin ? .limited : .enabled - self.isLimitedLogin = isLimitedLogin guard let configuration: LoginConfiguration = { if loginType == .limited { @@ -86,8 +52,8 @@ public class FacebookProviderAuthUI: FacebookProviderAuthUIProtocol { ) } }() else { - throw FacebookProviderError - .configurationInvalid("Failed to create Facebook login configuration") + throw AuthServiceError + .providerAuthenticationFailed("Failed to create Facebook login configuration") } let result = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< @@ -100,7 +66,8 @@ public class FacebookProviderAuthUI: FacebookProviderAuthUIProtocol { switch result { case .cancelled: continuation - .resume(throwing: FacebookProviderError.signInCancelled("User cancelled sign-in")) + .resume(throwing: AuthServiceError + .signInCancelled("User cancelled sign-in for Facebook")) case let .failed(error): continuation.resume(throwing: error) case .success: @@ -123,8 +90,8 @@ public class FacebookProviderAuthUI: FacebookProviderAuthUIProtocol { return credential } else { - throw FacebookProviderError - .accessToken( + throw AuthServiceError + .providerAuthenticationFailed( "Access token has expired or not available. Please sign-in with Facebook before attempting to create a Facebook provider credential" ) } @@ -133,18 +100,33 @@ public class FacebookProviderAuthUI: FacebookProviderAuthUIProtocol { private func limitedLogin() throws -> AuthCredential { if let idToken = AuthenticationToken.current { guard let nonce = rawNonce else { - throw FacebookProviderError - .limitedLoginNonce("`rawNonce` has not been generated for Facebook limited login") + throw AuthServiceError + .providerAuthenticationFailed( + "`rawNonce` has not been generated for Facebook limited login" + ) } let credential = OAuthProvider.credential(withProviderID: providerId, idToken: idToken.tokenString, rawNonce: nonce) return credential } else { - throw FacebookProviderError - .authenticationToken( + throw AuthServiceError + .providerAuthenticationFailed( "Authentication is not available. Please sign-in with Facebook before attempting to create a Facebook provider credential" ) } } } + +public class FacebookProviderAuthUI: AuthProviderUI { + public var provider: AuthProviderSwift + public let id: String = "facebook.com" + + public init(provider: AuthProviderSwift) { + self.provider = provider + } + + @MainActor public func authButton() -> AnyView { + AnyView(SignInWithFacebookButton(facebookProvider: provider as! FacebookProviderSwift)) + } +} diff --git a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift index 6ddfc1d681..1fd50dc472 100644 --- a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift +++ b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift @@ -12,120 +12,47 @@ // See the License for the specific language governing permissions and // limitations under the License. -import AppTrackingTransparency -import FacebookCore -import FacebookLogin import FirebaseAuth import FirebaseAuthSwiftUI +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI +/// A button for signing in with Facebook @MainActor public struct SignInWithFacebookButton { @Environment(AuthService.self) private var authService - @State private var errorMessage = "" - @State private var showCanceledAlert = false - @State private var limitedLogin = true - @State private var showUserTrackingAlert = false - @State private var trackingAuthorizationStatus: ATTrackingManager - .AuthorizationStatus = .notDetermined + @Environment(\.signInWithMergeConflictHandler) private var signInHandler + let facebookProvider: FacebookProviderSwift - public init() { - _trackingAuthorizationStatus = State(initialValue: ATTrackingManager - .trackingAuthorizationStatus) - } - - private var limitedLoginBinding: Binding { - Binding( - get: { self.limitedLogin }, - set: { newValue in - if trackingAuthorizationStatus == .authorized { - self.limitedLogin = newValue - } else { - self.limitedLogin = true - } - } - ) - } - - func requestTrackingPermission() { - ATTrackingManager.requestTrackingAuthorization { status in - Task { @MainActor in - trackingAuthorizationStatus = status - if status != .authorized { - showUserTrackingAlert = true - } - } - } + public init(facebookProvider: FacebookProviderSwift) { + self.facebookProvider = facebookProvider } } extension SignInWithFacebookButton: View { public var body: some View { - Button(action: { + AuthProviderButton( + label: authService.string.facebookLoginButtonLabel, + style: .facebook, + accessibilityId: "sign-in-with-facebook-button" + ) { Task { - do { - try await authService.signInWithFacebook(limitedLogin: limitedLogin) - } catch { - switch error { - case FacebookProviderError.signInCancelled: - showCanceledAlert = true - default: - errorMessage = authService.string.localizedErrorMessage(for: error) + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(facebookProvider) } + } else { + try? await authService.signIn(facebookProvider) } } - }) { - 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) - } - .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)) - ) - } } } } #Preview { FirebaseOptions.dummyConfigurationForPreview() - return SignInWithFacebookButton() + let facebookProvider = FacebookProviderSwift() + return SignInWithFacebookButton(facebookProvider: facebookProvider) .environment(AuthService()) } diff --git a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Services/AccountService+Google.swift b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Services/AccountService+Google.swift deleted file mode 100644 index f51d1501c4..0000000000 --- a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Services/AccountService+Google.swift +++ /dev/null @@ -1,68 +0,0 @@ -// 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. - -// -// AccountService+Google.swift -// FirebaseUI -// -// Created by Russell Wheatley on 22/05/2025. -// - -// -// AccountService+Facebook.swift -// FirebaseUI -// -// Created by Russell Wheatley on 14/05/2025. -// - -@preconcurrency import FirebaseAuth -import FirebaseAuthSwiftUI -import Observation - -protocol GoogleOperationReauthentication { - var googleProvider: GoogleProviderAuthUI { get } -} - -extension GoogleOperationReauthentication { - @MainActor func reauthenticate() async throws -> AuthenticationToken { - guard let user = Auth.auth().currentUser else { - throw AuthServiceError.reauthenticationRequired("No user currently signed-in") - } - - do { - let credential = try await googleProvider - .signInWithGoogle(clientID: googleProvider.clientID) - try await user.reauthenticate(with: credential) - - return .firebase("") - } catch { - throw AuthServiceError.signInFailed(underlying: error) - } - } -} - -@MainActor -class GoogleDeleteUserOperation: AuthenticatedOperation, - @preconcurrency GoogleOperationReauthentication { - let googleProvider: GoogleProviderAuthUI - init(googleProvider: GoogleProviderAuthUI) { - self.googleProvider = googleProvider - } - - func callAsFunction(on user: User) async throws { - try await callAsFunction(on: user) { - try await user.delete() - } - } -} diff --git a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Services/AuthService+Google.swift b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Services/AuthService+Google.swift index 2e51b84785..d968e83b2f 100644 --- a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Services/AuthService+Google.swift +++ b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Services/AuthService+Google.swift @@ -23,9 +23,9 @@ import FirebaseAuthSwiftUI public extension AuthService { @discardableResult - func withGoogleSignIn(scopes scopes: [String]? = nil) -> AuthService { - let clientID = auth.app?.options.clientID ?? "" - register(provider: GoogleProviderAuthUI(scopes: scopes, clientID: clientID)) + func withGoogleSignIn(_ provider: GoogleProviderSwift? = nil) -> AuthService { + registerProvider(providerWithButton: GoogleProviderAuthUI(provider: provider ?? + GoogleProviderSwift(clientID: auth.app?.options.clientID ?? ""))) return self } } diff --git a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Services/GoogleProviderAuthUI.swift b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Services/GoogleProviderAuthUI.swift index 236e66289f..907b683c74 100644 --- a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Services/GoogleProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Services/GoogleProviderAuthUI.swift @@ -19,41 +19,24 @@ import GoogleSignIn import GoogleSignInSwift import SwiftUI -let kGoogleUserInfoEmailScope = "https://www.googleapis.com/auth/userinfo.email" -let kGoogleUserInfoProfileScope = "https://www.googleapis.com/auth/userinfo.profile" -let kDefaultScopes = [kGoogleUserInfoEmailScope, kGoogleUserInfoProfileScope] - -public enum GoogleProviderError: Error { - case rootViewControllerNotFound(String) - case authenticationToken(String) - case user(String) -} - -public class GoogleProviderAuthUI: @preconcurrency GoogleProviderAuthUIProtocol { - public let id: String = "google" +public class GoogleProviderSwift: AuthProviderSwift { let scopes: [String] - let shortName = "Google" + let clientID: String let providerId = "google.com" - public let clientID: String - public init(scopes: [String]? = nil, clientID: String = FirebaseApp.app()!.options.clientID!) { - self.scopes = scopes ?? kDefaultScopes - self.clientID = clientID - } - - @MainActor public func authButton() -> AnyView { - // Moved to SignInWithGoogleButton so we could sign in via AuthService - AnyView(SignInWithGoogleButton()) - } - public func deleteUser(user: User) async throws { - let operation = GoogleDeleteUserOperation(googleProvider: self) - try await operation(on: user) + public init(scopes: [String] = [ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ], + clientID: String) { + self.clientID = clientID + self.scopes = scopes } - @MainActor public func signInWithGoogle(clientID: String) async throws -> AuthCredential { + @MainActor public func createAuthCredential() async throws -> AuthCredential { guard let presentingViewController = await (UIApplication.shared.connectedScenes .first as? UIWindowScene)?.windows.first?.rootViewController else { - throw GoogleProviderError + throw AuthServiceError .rootViewControllerNotFound( "Root View controller is not available to present Google sign-in View." ) @@ -74,7 +57,8 @@ public class GoogleProviderAuthUI: @preconcurrency GoogleProviderAuthUIProtocol guard let user = result?.user, let idToken = user.idToken?.tokenString else { continuation - .resume(throwing: GoogleProviderError.user("Failed to retrieve user or idToken.")) + .resume(throwing: AuthServiceError + .providerAuthenticationFailed("Failed to retrieve user or idToken.")) return } @@ -85,3 +69,16 @@ public class GoogleProviderAuthUI: @preconcurrency GoogleProviderAuthUIProtocol } } } + +public class GoogleProviderAuthUI: AuthProviderUI { + public var provider: AuthProviderSwift + public let id: String = "google.com" + + public init(provider: AuthProviderSwift) { + self.provider = provider + } + + @MainActor public func authButton() -> AnyView { + AnyView(SignInWithGoogleButton(googleProvider: provider)) + } +} diff --git a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift index 634f33cbdc..a4f0710cff 100644 --- a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift +++ b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift @@ -19,26 +19,36 @@ // Created by Russell Wheatley on 22/05/2025. // import FirebaseAuthSwiftUI +import FirebaseAuthUIComponents import FirebaseCore -import GoogleSignInSwift import SwiftUI @MainActor public struct SignInWithGoogleButton { @Environment(AuthService.self) private var authService + @Environment(\.signInWithMergeConflictHandler) private var signInHandler + let googleProvider: AuthProviderSwift - let customViewModel = GoogleSignInButtonViewModel( - scheme: .light, - style: .wide, - state: .normal - ) + public init(googleProvider: AuthProviderSwift) { + self.googleProvider = googleProvider + } } extension SignInWithGoogleButton: View { public var body: some View { - GoogleSignInButton(viewModel: customViewModel) { + AuthProviderButton( + label: "Sign in with Google", + style: .google, + accessibilityId: "sign-in-with-google-button" + ) { Task { - try await authService.signInWithGoogle() + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(googleProvider) + } + } else { + try? await authService.signIn(googleProvider) + } } } } @@ -46,6 +56,7 @@ extension SignInWithGoogleButton: View { #Preview { FirebaseOptions.dummyConfigurationForPreview() - return SignInWithGoogleButton() + let googleProvider = GoogleProviderSwift(clientID: "") + return SignInWithGoogleButton(googleProvider: googleProvider) .environment(AuthService()) } diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/AuthService+OAuth.swift b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/AuthService+OAuth.swift new file mode 100644 index 0000000000..b4b6034af6 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/AuthService+OAuth.swift @@ -0,0 +1,30 @@ +// 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. + +// +// AuthService+OAuth.swift +// FirebaseUI +// +// Created by Russell Wheatley on 21/10/2025. +// + +import FirebaseAuthSwiftUI + +public extension AuthService { + @discardableResult + func withOAuthSignIn(_ provider: OAuthProviderSwift) -> AuthService { + registerProvider(providerWithButton: OAuthProviderAuthUI(provider: provider)) + return self + } +} diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift+Presets.swift b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift+Presets.swift new file mode 100644 index 0000000000..f87f3b1a34 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift+Presets.swift @@ -0,0 +1,66 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuthUIComponents +import SwiftUI + +/// Preset configurations for common OAuth providers +public extension OAuthProviderSwift { + /// GitHub OAuth provider + /// - Parameters: + /// - scopes: GitHub scopes (default: ["user"]) + /// - Returns: Configured GitHub provider + static func github(scopes: [String] = ["user"]) -> OAuthProviderSwift { + return OAuthProviderSwift( + providerId: "github.com", + scopes: scopes, + displayName: "Sign in with GitHub", + buttonIcon: ProviderStyle.github.icon!, + buttonBackgroundColor: ProviderStyle.github.backgroundColor, + buttonForegroundColor: ProviderStyle.github.contentColor + ) + } + + /// Microsoft OAuth provider + /// - Parameters: + /// - scopes: Microsoft scopes (default: ["user.readwrite"]) + /// - Returns: Configured Microsoft provider + static func microsoft(scopes: [String] = ["user.readwrite"]) -> OAuthProviderSwift { + return OAuthProviderSwift( + providerId: "microsoft.com", + scopes: scopes, + customParameters: ["prompt": "consent"], + displayName: "Sign in with Microsoft", + buttonIcon: ProviderStyle.microsoft.icon!, + buttonBackgroundColor: ProviderStyle.microsoft.backgroundColor, + buttonForegroundColor: ProviderStyle.microsoft.contentColor + ) + } + + /// Yahoo OAuth provider + /// - Parameters: + /// - scopes: Yahoo scopes (default: ["user.readwrite"]) + /// - Returns: Configured Yahoo provider + static func yahoo(scopes: [String] = ["user.readwrite"]) -> OAuthProviderSwift { + return OAuthProviderSwift( + providerId: "yahoo.com", + scopes: scopes, + customParameters: ["prompt": "consent"], + displayName: "Sign in with Yahoo", + buttonIcon: ProviderStyle.yahoo.icon!, + buttonBackgroundColor: ProviderStyle.yahoo.backgroundColor, + buttonForegroundColor: ProviderStyle.yahoo.contentColor + ) + } +} diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift.swift b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift.swift new file mode 100644 index 0000000000..abdccbcd9b --- /dev/null +++ b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Services/OAuthProviderSwift.swift @@ -0,0 +1,135 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +import FirebaseAuthSwiftUI +import FirebaseCore +import SwiftUI + +/// Configuration for a generic OAuth provider +public class OAuthProviderSwift: AuthProviderSwift { + 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") + /// - scopes: OAuth scopes to request + /// - customParameters: Additional OAuth parameters + /// - displayName: Button label (e.g., "Sign in with GitHub") + /// - buttonIcon: Button icon image + /// - buttonBackgroundColor: Button background color + /// - buttonForegroundColor: Button text/icon color + public init(providerId: String, + scopes: [String] = [], + customParameters: [String: String] = [:], + displayName: String, + buttonIcon: Image, + buttonBackgroundColor: Color = .black, + buttonForegroundColor: Color = .white) { + self.providerId = providerId + self.scopes = scopes + self.customParameters = customParameters + self.displayName = displayName + self.buttonIcon = buttonIcon + self.buttonBackgroundColor = buttonBackgroundColor + self.buttonForegroundColor = buttonForegroundColor + } + + /// Convenience initializer using SF Symbol + /// - Parameters: + /// - providerId: The OAuth provider ID (e.g., "github.com", "microsoft.com") + /// - scopes: OAuth scopes to request + /// - customParameters: Additional OAuth parameters + /// - displayName: Button label (e.g., "Sign in with GitHub") + /// - iconSystemName: SF Symbol name + /// - buttonBackgroundColor: Button background color + /// - buttonForegroundColor: Button text/icon color + public convenience init(providerId: String, + scopes: [String] = [], + customParameters: [String: String] = [:], + displayName: String, + iconSystemName: String, + buttonBackgroundColor: Color = .black, + buttonForegroundColor: Color = .white) { + self.init( + providerId: providerId, + scopes: scopes, + customParameters: customParameters, + displayName: displayName, + buttonIcon: Image(systemName: iconSystemName), + buttonBackgroundColor: buttonBackgroundColor, + buttonForegroundColor: buttonForegroundColor + ) + } + + @MainActor public func createAuthCredential() async throws -> AuthCredential { + let provider = OAuthProvider(providerID: providerId) + + // Set scopes if provided + if !scopes.isEmpty { + provider.scopes = scopes + } + // Set custom parameters if provided + if !customParameters.isEmpty { + provider.customParameters = customParameters + } + + return try await withCheckedThrowingContinuation { continuation in + provider.getCredentialWith(nil) { credential, error in + if let error = error { + continuation.resume( + throwing: AuthServiceError.signInFailed(underlying: error) + ) + return + } + + guard let credential = credential else { + continuation.resume( + throwing: AuthServiceError.invalidCredentials( + "\(self.providerId) did not provide a valid AuthCredential" + ) + ) + return + } + + continuation.resume(returning: credential) + } + } + } +} + +public class OAuthProviderAuthUI: AuthProviderUI { + public var provider: AuthProviderSwift + + public init(provider: AuthProviderSwift) { + self.provider = provider + } + + public var id: String { + guard let oauthProvider = provider as? OAuthProviderSwift else { + return "oauth.unknown" + } + 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 new file mode 100644 index 0000000000..0289e16130 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.swift @@ -0,0 +1,66 @@ +// 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 FirebaseAuthSwiftUI +import FirebaseAuthUIComponents +import SwiftUI + +/// A generic OAuth sign-in button that adapts to any provider's configuration +@MainActor +public struct GenericOAuthButton { + @Environment(AuthService.self) private var authService + @Environment(\.signInWithMergeConflictHandler) private var signInHandler + let provider: AuthProviderSwift + public init(provider: AuthProviderSwift) { + self.provider = provider + } +} + +extension GenericOAuthButton: View { + public var body: some View { + guard let oauthProvider = provider as? OAuthProviderSwift else { + return AnyView( + Text("Invalid OAuth Provider") + .foregroundColor(.red) + ) + } + + // Create custom style from provider configuration + var resolvedStyle: ProviderStyle { + ProviderStyle( + icon: oauthProvider.buttonIcon, + backgroundColor: oauthProvider.buttonBackgroundColor, + contentColor: oauthProvider.buttonForegroundColor + ) + } + + return AnyView( + AuthProviderButton( + label: oauthProvider.displayName, + style: resolvedStyle, + accessibilityId: "sign-in-with-\(oauthProvider.providerId)-button" + ) { + Task { + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(provider) + } + } else { + try? await authService.signIn(provider) + } + } + } + ) + } +} diff --git a/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Tests/FirebaseOAuthSwiftUITests/FirebaseOAuthSwiftUITests.swift b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Tests/FirebaseOAuthSwiftUITests/FirebaseOAuthSwiftUITests.swift new file mode 100644 index 0000000000..31b3bfd5bf --- /dev/null +++ b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Tests/FirebaseOAuthSwiftUITests/FirebaseOAuthSwiftUITests.swift @@ -0,0 +1,20 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@testable import FirebaseOAuthSwiftUI +import Testing + +@Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. +} diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/AuthService+Phone.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/AuthService+Phone.swift index 462eb9a96f..b04384b49c 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/AuthService+Phone.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/AuthService+Phone.swift @@ -24,7 +24,7 @@ import FirebaseAuthSwiftUI public extension AuthService { @discardableResult func withPhoneSignIn() -> AuthService { - register(provider: PhoneAuthProviderAuthUI()) + registerProvider(providerWithButton: PhoneAuthProviderAuthUI()) return self } } diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift index 13bf1c7956..2f5f05bd02 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift @@ -12,29 +12,301 @@ // See the License for the specific language governing permissions and // limitations under the License. +import Combine @preconcurrency import FirebaseAuth import FirebaseAuthSwiftUI +import FirebaseAuthUIComponents import SwiftUI +import UIKit public typealias VerificationID = String -public class PhoneAuthProviderAuthUI: @preconcurrency PhoneAuthProviderAuthUIProtocol { - public let id: String = "phone" +// MARK: - Phone Auth Coordinator - @MainActor public func authButton() -> AnyView { - AnyView(PhoneAuthButtonView()) +@MainActor +private class PhoneAuthCoordinator: ObservableObject { + @Published var isPresented = true + @Published var currentStep: Step = .enterPhoneNumber + @Published var phoneNumber = "" + @Published var selectedCountry: CountryData = .default + @Published var verificationID = "" + @Published var fullPhoneNumber = "" + @Published var verificationCode = "" + @Published var currentError: AlertError? + @Published var isProcessing = false + + var continuation: CheckedContinuation? + + enum Step { + case enterPhoneNumber + case enterVerificationCode } - @MainActor public func verifyPhoneNumber(phoneNumber: String) async throws -> VerificationID { - return try await withCheckedThrowingContinuation { continuation in - PhoneAuthProvider.provider() - .verifyPhoneNumber(phoneNumber, uiDelegate: nil) { verificationID, error in - if let error = error { - continuation.resume(throwing: error) - return + func sendVerificationCode() async { + isProcessing = true + do { + fullPhoneNumber = selectedCountry.dialCode + phoneNumber + verificationID = try await withCheckedThrowingContinuation { continuation in + PhoneAuthProvider.provider() + .verifyPhoneNumber(fullPhoneNumber, uiDelegate: nil) { verificationID, error in + if let error = error { + continuation.resume(throwing: error) + return + } + continuation.resume(returning: verificationID!) } - continuation.resume(returning: verificationID!) + } + currentStep = .enterVerificationCode + currentError = nil + } catch { + currentError = AlertError(message: error.localizedDescription) + } + isProcessing = false + } + + func verifyCodeAndComplete() async { + isProcessing = true + do { + let credential = PhoneAuthProvider.provider() + .credential(withVerificationID: verificationID, verificationCode: verificationCode) + + isPresented = false + continuation?.resume(returning: credential) + continuation = nil + } catch { + currentError = AlertError(message: error.localizedDescription) + isProcessing = false + } + } + + func cancel() { + isPresented = false + + // Only throw error if user has started the flow (sent verification code) + // If they cancel before entering/sending phone number, dismiss silently + if !verificationID.isEmpty { + continuation? + .resume(throwing: AuthServiceError.signInCancelled("Phone authentication was cancelled")) + } else { + continuation?.resume(throwing: CancellationError()) + } + + continuation = nil + } +} + +// MARK: - Phone Auth Flow View + +@MainActor +private struct PhoneAuthFlowView: View { + @StateObject var coordinator: PhoneAuthCoordinator + @Environment(AuthService.self) private var authService + + var body: some View { + NavigationStack { + Group { + switch coordinator.currentStep { + case .enterPhoneNumber: + phoneNumberView + case .enterVerificationCode: + verificationCodeView + } + } + .toolbar { + toolbar + } + } + .interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled) + } + + @ToolbarContentBuilder + var toolbar: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + if !authService.configuration.shouldHideCancelButton { + Button { + coordinator.cancel() + } label: { + Image(systemName: "xmark") } + } } } + + // MARK: - Phone Number View + + var phoneNumberView: some View { + VStack(spacing: 16) { + Text(authService.string.enterPhoneNumberPlaceholder) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top) + + AuthTextField( + text: $coordinator.phoneNumber, + localizedTitle: "Phone", + prompt: authService.string.enterPhoneNumberPlaceholder, + keyboardType: .phonePad, + contentType: .telephoneNumber, + onChange: { _ in } + ) { + CountrySelector( + selectedCountry: $coordinator.selectedCountry, + enabled: !coordinator.isProcessing + ) + } + + Button(action: { + Task { + await coordinator.sendVerificationCode() + } + }) { + if coordinator.isProcessing { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.sendCodeButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(coordinator.isProcessing || coordinator.phoneNumber.isEmpty) + .padding(.top, 8) + + Spacer() + } + .navigationTitle(authService.string.phoneSignInTitle) + .padding(.horizontal) + .errorAlert(error: $coordinator.currentError, okButtonLabel: authService.string.okButtonLabel) + } + + // MARK: - Verification Code View + + var verificationCodeView: some View { + VStack(spacing: 32) { + VStack(spacing: 16) { + VStack(spacing: 8) { + Text("We sent a code to \(coordinator.fullPhoneNumber)") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + coordinator.currentStep = .enterPhoneNumber + coordinator.verificationCode = "" + } label: { + Text("Change number") + .font(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.bottom) + .frame(maxWidth: .infinity, alignment: .leading) + + VerificationCodeInputField( + code: $coordinator.verificationCode, + isError: coordinator.currentError != nil, + errorMessage: coordinator.currentError?.message + ) + + Button(action: { + Task { + await coordinator.verifyCodeAndComplete() + } + }) { + if coordinator.isProcessing { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.verifyAndSignInButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(coordinator.isProcessing || coordinator.verificationCode.count != 6) + } + + Spacer() + } + .navigationTitle(authService.string.enterVerificationCodeTitle) + .navigationBarTitleDisplayMode(.inline) + .padding(.horizontal) + .errorAlert(error: $coordinator.currentError, okButtonLabel: authService.string.okButtonLabel) + } +} + +// MARK: - Phone Provider Swift + +public class PhoneProviderSwift: PhoneAuthProviderSwift { + private var cancellables = Set() + + // Internal use only: Injected automatically by AuthService.signIn() + public weak var authService: AuthService? + + public init() {} + + @MainActor public func createAuthCredential() async throws -> AuthCredential { + guard let authService = authService else { + throw AuthServiceError.providerAuthenticationFailed( + "AuthService not injected. This should be set automatically by AuthService.signIn()." + ) + } + + // Get the root view controller to present from + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.windows.first?.rootViewController else { + throw AuthServiceError.rootViewControllerNotFound( + "Root view controller not available to present phone auth flow" + ) + } + + // Find the topmost view controller + var topViewController = rootViewController + while let presented = topViewController.presentedViewController { + topViewController = presented + } + + // Create coordinator + let coordinator = PhoneAuthCoordinator() + + // Present the flow and wait for result + return try await withCheckedThrowingContinuation { continuation in + coordinator.continuation = continuation + + // Create SwiftUI view with environment + let flowView = PhoneAuthFlowView(coordinator: coordinator) + .environment(authService) + + let hostingController = UIHostingController(rootView: flowView) + + // Dismiss handler - watch for presentation state changes + coordinator.$isPresented.sink { [weak hostingController] isPresented in + if !isPresented { + hostingController?.dismiss(animated: true) + } + }.store(in: &cancellables) + + // Present modally + topViewController.present(hostingController, animated: true) + } + } +} + +public class PhoneAuthProviderAuthUI: AuthProviderUI { + public var provider: AuthProviderSwift + public let id: String = "phone" + + public init(provider: PhoneAuthProviderSwift? = nil) { + self.provider = provider ?? PhoneProviderSwift() + } + + @MainActor public func authButton() -> AnyView { + AnyView(PhoneAuthButtonView(phoneProvider: provider as! PhoneAuthProviderSwift)) + } } diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift index 528519809a..f2cd7c3ed4 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift @@ -13,36 +13,44 @@ // limitations under the License. import FirebaseAuthSwiftUI +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor public struct PhoneAuthButtonView { @Environment(AuthService.self) private var authService + @Environment(\.signInWithMergeConflictHandler) private var signInHandler + let phoneProvider: PhoneAuthProviderSwift - public init() {} + public init(phoneProvider: PhoneAuthProviderSwift) { + self.phoneProvider = phoneProvider + } } extension PhoneAuthButtonView: View { public var body: some View { - Button(action: { - authService.registerModalView(for: .phoneAuth) { - AnyView(PhoneAuthView().environment(authService)) + AuthProviderButton( + label: "Sign in with Phone", + style: .phone, + accessibilityId: "sign-in-with-phone-button" + ) { + Task { + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(phoneProvider) + } + } else { + try? await authService.signIn(phoneProvider) + } } - authService.presentModal(for: .phoneAuth) - }) { - Label("Sign in with Phone", systemImage: "phone.fill") - .foregroundColor(.white) - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.green.opacity(0.8)) // Light green - .cornerRadius(8) } } } #Preview { FirebaseOptions.dummyConfigurationForPreview() - return PhoneAuthButtonView() + let phoneProvider = PhoneProviderSwift() + return PhoneAuthButtonView(phoneProvider: phoneProvider) .environment(AuthService()) } diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthView.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthView.swift deleted file mode 100644 index 6483c3e0bb..0000000000 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthView.swift +++ /dev/null @@ -1,120 +0,0 @@ -// 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. - -// -// PhoneAuthView.swift -// FirebaseUI -// -// Created by Russell Wheatley on 14/05/2025. -// - -import FirebaseAuthSwiftUI -import FirebaseCore -import SwiftUI - -@MainActor -public struct PhoneAuthView { - @Environment(AuthService.self) private var authService - @State private var errorMessage = "" - @State private var phoneNumber = "" - @State private var showVerificationCodeInput = false - @State private var verificationCode = "" - @State private var verificationID = "" - - public init() {} -} - -extension PhoneAuthView: View { - public var body: some View { - if authService.authenticationState != .authenticating { - VStack { - LabeledContent { - TextField(authService.string.enterPhoneNumberLabel, text: $phoneNumber) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .submitLabel(.next) - } label: { - Image(systemName: "at") - }.padding(.vertical, 6) - .background(Divider(), alignment: .bottom) - .padding(.bottom, 4) - Button(action: { - Task { - do { - let id = try await authService.verifyPhoneNumber(phoneNumber: phoneNumber) - verificationID = id - showVerificationCodeInput = true - } catch { - errorMessage = authService.string.localizedErrorMessage( - for: error - ) - } - } - }) { - Text(authService.string.smsCodeSendButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .disabled(!PhoneUtils.isValidPhoneNumber(phoneNumber)) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .buttonStyle(.borderedProminent) - Text(errorMessage).foregroundColor(.red) - }.sheet(isPresented: $showVerificationCodeInput) { - TextField(authService.string.phoneNumberVerificationCodeLabel, text: $verificationCode) - .keyboardType(.numberPad) - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - .padding(.horizontal) - - Button(action: { - Task { - do { - try await authService.signInWithPhoneNumber( - verificationID: verificationID, - verificationCode: verificationCode - ) - } catch { - errorMessage = authService.string.localizedErrorMessage(for: error) - } - showVerificationCodeInput = false - authService.dismissModal() - } - }) { - Text(authService.string.verifyPhoneNumberAndSignInLabel) - .foregroundColor(.white) - .padding() - .frame(maxWidth: .infinity) - .background(Color.green) - .cornerRadius(8) - .padding(.horizontal) - } - }.onOpenURL { url in - authService.auth.canHandle(url) - } - } else { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - } -} - -#Preview { - FirebaseOptions.dummyConfigurationForPreview() - return PhoneAuthView() - .environment(AuthService()) -} diff --git a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Services/AuthService+Twitter.swift b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Services/AuthService+Twitter.swift new file mode 100644 index 0000000000..8a75e10369 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Services/AuthService+Twitter.swift @@ -0,0 +1,31 @@ +// 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. + +// +// AuthService+Twitter.swift +// FirebaseUI +// +// Created by Russell Wheatley on 01/05/2025. +// + +import FirebaseAuthSwiftUI + +public extension AuthService { + @discardableResult + func withTwitterSignIn(_ provider: TwitterProviderSwift? = nil) -> AuthService { + registerProvider(providerWithButton: TwitterProviderAuthUI(provider: provider ?? + TwitterProviderSwift())) + return self + } +} diff --git a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Services/TwitterProviderAuthUI.swift b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Services/TwitterProviderAuthUI.swift new file mode 100644 index 0000000000..1afb97d1e5 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Services/TwitterProviderAuthUI.swift @@ -0,0 +1,59 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +import FirebaseAuthSwiftUI +import FirebaseCore +import SwiftUI + +public class TwitterProviderSwift: AuthProviderSwift { + public let scopes: [String] + let providerId = "twitter.com" + + public init(scopes: [String] = ["user.readwrite"]) { + self.scopes = scopes + } + + @MainActor public func createAuthCredential() async throws -> AuthCredential { + let provider = OAuthProvider(providerID: providerId) + return try await withCheckedThrowingContinuation { continuation in + provider.getCredentialWith(nil) { credential, error in + if let error { + continuation + .resume(throwing: AuthServiceError.signInFailed(underlying: error)) + } else if let credential { + continuation.resume(returning: credential) + } else { + continuation + .resume(throwing: AuthServiceError + .invalidCredentials("Twitter did not provide a valid AuthCredential")) + } + } + } + } +} + +public class TwitterProviderAuthUI: AuthProviderUI { + public var provider: AuthProviderSwift + + public init(provider: AuthProviderSwift) { + self.provider = provider + } + + public let id: String = "twitter.com" + + @MainActor public func authButton() -> AnyView { + AnyView(SignInWithTwitterButton(provider: provider)) + } +} diff --git a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift new file mode 100644 index 0000000000..93516bc2e2 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift @@ -0,0 +1,48 @@ +// 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 FirebaseAuthSwiftUI +import FirebaseAuthUIComponents +import SwiftUI + +/// A button for signing in with Twitter/X +@MainActor +public struct SignInWithTwitterButton { + @Environment(AuthService.self) private var authService + @Environment(\.signInWithMergeConflictHandler) private var signInHandler + let provider: AuthProviderSwift + public init(provider: AuthProviderSwift) { + self.provider = provider + } +} + +extension SignInWithTwitterButton: View { + public var body: some View { + AuthProviderButton( + label: "Sign in with X", + style: .twitter, + accessibilityId: "sign-in-with-twitter-button" + ) { + Task { + if let handler = signInHandler { + try? await handler(authService) { + try await authService.signIn(provider) + } + } else { + try? await authService.signIn(provider) + } + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Tests/FirebaseTwitterSwiftUITests/FirebaseTwitterSwiftUITests.swift b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Tests/FirebaseTwitterSwiftUITests/FirebaseTwitterSwiftUITests.swift new file mode 100644 index 0000000000..f6bb2e3ec0 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Tests/FirebaseTwitterSwiftUITests/FirebaseTwitterSwiftUITests.swift @@ -0,0 +1,20 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@testable import FirebaseTwitterSwiftUI +import Testing + +@Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. +} diff --git a/Package.swift b/Package.swift index d3cba1ce9d..d91de108fa 100644 --- a/Package.swift +++ b/Package.swift @@ -78,30 +78,37 @@ let package = Package( name: "FirebasePhoneAuthSwiftUI", targets: ["FirebasePhoneAuthSwiftUI"] ), + .library( + name: "FirebaseTwitterSwiftUI", + targets: ["FirebaseTwitterSwiftUI"] + ), + .library( + name: "FirebaseAppleSwiftUI", + targets: ["FirebaseAppleSwiftUI"] + ), + .library( + name: "FirebaseOAuthSwiftUI", + targets: ["FirebaseOAuthSwiftUI"] + ), ], dependencies: [ .package( - name: "Facebook", url: "https://github.com/facebook/facebook-ios-sdk.git", "17.0.0" ..< "18.0.0" ), .package( - name: "Firebase", url: "https://github.com/firebase/firebase-ios-sdk.git", "8.0.0" ..< "13.0.0" ), .package( - name: "GoogleSignIn", url: "https://github.com/google/GoogleSignIn-iOS", from: "7.0.0" ), .package( - name: "GoogleUtilities", url: "https://github.com/google/GoogleUtilities.git", "7.4.1" ..< "9.0.0" ), .package( - name: "SDWebImage", url: "https://github.com/SDWebImage/SDWebImage.git", from: "5.0.0" ), @@ -124,7 +131,7 @@ let package = Package( .target( name: "FirebaseDatabaseUI", dependencies: [ - .product(name: "FirebaseDatabase", package: "Firebase"), + .product(name: "FirebaseDatabase", package: "firebase-ios-sdk"), ], path: "FirebaseDatabaseUI/Sources", exclude: ["Info.plist"], @@ -137,7 +144,7 @@ let package = Package( .target( name: "FirebaseAuthUI", dependencies: [ - .product(name: "FirebaseAuth", package: "Firebase"), + .product(name: "FirebaseAuth", package: "firebase-ios-sdk"), .product(name: "GULUserDefaults", package: "GoogleUtilities"), ], path: "FirebaseAuthUI/Sources", @@ -168,8 +175,8 @@ let package = Package( name: "FirebaseFacebookAuthUI", dependencies: [ "FirebaseAuthUI", - .product(name: "FacebookLogin", package: "Facebook"), - .product(name: "FacebookCore", package: "Facebook"), + .product(name: "FacebookLogin", package: "facebook-ios-sdk"), + .product(name: "FacebookCore", package: "facebook-ios-sdk"), ], path: "FirebaseFacebookAuthUI/Sources", exclude: ["Info.plist"], @@ -185,7 +192,7 @@ let package = Package( .target( name: "FirebaseFirestoreUI", dependencies: [ - .product(name: "FirebaseFirestore", package: "Firebase"), + .product(name: "FirebaseFirestore", package: "firebase-ios-sdk"), ], path: "FirebaseFirestoreUI/Sources", exclude: ["Info.plist"], @@ -199,7 +206,7 @@ let package = Package( name: "FirebaseGoogleAuthUI", dependencies: [ "FirebaseAuthUI", - "GoogleSignIn", + .product(name: "GoogleSignIn", package: "GoogleSignIn-iOS"), ], path: "FirebaseGoogleAuthUI/Sources", exclude: ["Info.plist"], @@ -246,7 +253,7 @@ let package = Package( .target( name: "FirebaseStorageUI", dependencies: [ - .product(name: "FirebaseStorage", package: "Firebase"), + .product(name: "FirebaseStorage", package: "firebase-ios-sdk"), .product(name: "SDWebImage", package: "SDWebImage"), ], path: "FirebaseStorageUI/Sources", @@ -257,60 +264,153 @@ let package = Package( .headerSearchPath("../../"), ] ), + .target( + name: "FirebaseAuthUIComponents", + dependencies: [], + path: "FirebaseSwiftUI/FirebaseAuthUIComponents/Sources", + resources: [ + .process("Resources"), + ] + ), .target( name: "FirebaseAuthSwiftUI", dependencies: [ - .product(name: "FirebaseAuth", package: "Firebase"), + "FirebaseAuthUIComponents", + .product(name: "FirebaseAuth", package: "firebase-ios-sdk"), ], path: "FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources", resources: [ .process("Strings"), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), ] ), .testTarget( name: "FirebaseAuthSwiftUITests", dependencies: ["FirebaseAuthSwiftUI"], - path: "FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/" + path: "FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] ), .target( name: "FirebaseGoogleSwiftUI", dependencies: [ "FirebaseAuthSwiftUI", - "GoogleSignIn", - .product(name: "GoogleSignInSwift", package: "GoogleSignIn"), + "FirebaseAuthUIComponents", + .product(name: "GoogleSignIn", package: "GoogleSignIn-iOS"), + .product(name: "GoogleSignInSwift", package: "GoogleSignIn-iOS"), ], - path: "FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources" + path: "FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] ), .testTarget( name: "FirebaseGoogleSwiftUITests", dependencies: ["FirebaseGoogleSwiftUI"], - path: "FirebaseSwiftUI/FirebaseGoogleSwiftUI/Tests/" + path: "FirebaseSwiftUI/FirebaseGoogleSwiftUI/Tests/", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] ), .target( name: "FirebaseFacebookSwiftUI", dependencies: [ "FirebaseAuthSwiftUI", - .product(name: "FacebookLogin", package: "Facebook"), - .product(name: "FacebookCore", package: "Facebook"), + "FirebaseAuthUIComponents", + .product(name: "FacebookLogin", package: "facebook-ios-sdk"), + .product(name: "FacebookCore", package: "facebook-ios-sdk"), ], - path: "FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources" + path: "FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] ), .testTarget( name: "FirebaseFacebookSwiftUITests", dependencies: ["FirebaseFacebookSwiftUI"], - path: "FirebaseSwiftUI/FirebaseFacebookSwiftUI/Tests/" + path: "FirebaseSwiftUI/FirebaseFacebookSwiftUI/Tests/", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] ), .target( name: "FirebasePhoneAuthSwiftUI", dependencies: [ "FirebaseAuthSwiftUI", + "FirebaseAuthUIComponents", ], - path: "FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources" + path: "FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] ), .testTarget( name: "FirebasePhoneAuthSwiftUITests", dependencies: ["FirebasePhoneAuthSwiftUI"], - path: "FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Tests/" + path: "FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Tests/", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), + .target( + name: "FirebaseTwitterSwiftUI", + dependencies: [ + "FirebaseAuthSwiftUI", + "FirebaseAuthUIComponents", + ], + path: "FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), + .testTarget( + name: "FirebaseTwitterSwiftUITests", + dependencies: ["FirebaseTwitterSwiftUI"], + path: "FirebaseSwiftUI/FirebaseTwitterSwiftUI/Tests/", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), + .target( + name: "FirebaseAppleSwiftUI", + dependencies: [ + "FirebaseAuthSwiftUI", + "FirebaseAuthUIComponents", + ], + path: "FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), + .testTarget( + name: "FirebaseAppleSwiftUITests", + dependencies: ["FirebaseAppleSwiftUI"], + path: "FirebaseSwiftUI/FirebaseAppleSwiftUI/Tests/", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), + .target( + name: "FirebaseOAuthSwiftUI", + dependencies: [ + "FirebaseAuthSwiftUI", + "FirebaseAuthUIComponents", + ], + path: "FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), + .testTarget( + name: "FirebaseOAuthSwiftUITests", + dependencies: ["FirebaseOAuthSwiftUI"], + path: "FirebaseSwiftUI/FirebaseOAuthSwiftUI/Tests/", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] ), ] ) diff --git a/release-swift.sh b/release-swift.sh new file mode 100755 index 0000000000..07c38bf814 --- /dev/null +++ b/release-swift.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash + +# Script to version FirebaseAuthSwiftUI package +# This script will: +# 1. Check we're on main branch with clean working directory +# 2. Get latest git tag +# 3. Prompt for new version +# 4. Update Version.swift +# 5. Commit, tag, and push changes +# +# Usage: +# ./release-swift.sh # Normal mode (actually commits and pushes) +# ./release-swift.sh --dry-run # Dry run mode (simulates without pushing) + +set -euo pipefail + +# Check for dry-run flag +DRY_RUN=false +if [ "${1:-}" = "--dry-run" ]; then + DRY_RUN=true + echo -e "\033[1;33m⚠️ DRY RUN MODE - No changes will be pushed to remote ⚠️\033[0m" + echo "" +fi + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +VERSION_FILE="FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Version.swift" + +echo -e "${GREEN}=== FirebaseAuthSwiftUI Version Release Script ===${NC}" +echo "" + +# Check if we're on main branch +echo "Checking current branch..." +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) +if [ "$CURRENT_BRANCH" != "main" ]; then + echo -e "${RED}Error: Not on main branch (currently on: $CURRENT_BRANCH)${NC}" + echo "Please switch to main branch before running this script." + exit 1 +fi +echo -e "${GREEN}βœ“ On main branch${NC}" +echo "" + +# Check if working directory is clean +echo "Checking working directory status..." +if ! git diff-index --quiet HEAD --; then + echo -e "${RED}Error: Working directory is not clean${NC}" + echo "Please commit or stash your changes before running this script." + echo "" + echo "Current status:" + git status --short + exit 1 +fi +echo -e "${GREEN}βœ“ Working directory is clean${NC}" +echo "" + +# Get the latest tag +echo "Fetching latest tags from remote..." +git fetch --tags --quiet + +LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") +if [ -z "$LATEST_TAG" ]; then + echo -e "${YELLOW}No existing tags found${NC}" + LATEST_VERSION="none" +else + echo "Latest tag: $LATEST_TAG" + # Remove 'v' prefix if present + LATEST_VERSION="${LATEST_TAG#v}" +fi +echo "" + +# Prompt for new version +echo -e "${YELLOW}Enter the new version number (e.g., 15.0.2):${NC}" +read -r NEW_VERSION + +# Validate semantic versioning format +if ! [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo -e "${RED}Error: Invalid version format${NC}" + echo "Version must follow semantic versioning (X.Y.Z where X, Y, Z are numbers)" + exit 1 +fi +echo -e "${GREEN}βœ“ Valid semantic version format${NC}" +echo "" + +# Add 'v' prefix and confirm +NEW_TAG="v${NEW_VERSION}" +echo -e "${YELLOW}Version will be tagged as: ${GREEN}${NEW_TAG}${NC}" +echo "Previous version: ${LATEST_VERSION}" +echo "" +echo -e "${YELLOW}Confirm this version? (y/n):${NC}" +read -r CONFIRM + +if [ "$CONFIRM" != "y" ] && [ "$CONFIRM" != "Y" ]; then + echo -e "${RED}Version release cancelled${NC}" + exit 0 +fi +echo "" + +# Check if tag already exists +if git rev-parse "$NEW_TAG" >/dev/null 2>&1; then + echo -e "${RED}Error: Tag $NEW_TAG already exists${NC}" + echo "Please choose a different version number." + exit 1 +fi +echo -e "${GREEN}βœ“ Tag $NEW_TAG does not exist${NC}" +echo "" + +# Update Version.swift file +echo "Updating $VERSION_FILE..." +if [ ! -f "$VERSION_FILE" ]; then + echo -e "${RED}Error: $VERSION_FILE not found${NC}" + exit 1 +fi + +# Create backup +cp "$VERSION_FILE" "${VERSION_FILE}.bak" + +# Update the version in the file +sed -i.tmp "s/public static let version = \".*\"/public static let version = \"${NEW_VERSION}\"/" "$VERSION_FILE" +rm "${VERSION_FILE}.tmp" + +# Show the changes +echo "" +echo -e "${YELLOW}Changes to be committed:${NC}" +echo "---" +git diff "$VERSION_FILE" +echo "---" +echo "" + +echo -e "${YELLOW}Proceed with commit, tag, and push? (y/n):${NC}" +read -r FINAL_CONFIRM + +if [ "$FINAL_CONFIRM" != "y" ] && [ "$FINAL_CONFIRM" != "Y" ]; then + echo -e "${YELLOW}Restoring backup and cancelling...${NC}" + mv "${VERSION_FILE}.bak" "$VERSION_FILE" + exit 0 +fi + +# Remove backup +rm "${VERSION_FILE}.bak" + +# Commit the changes +echo "" +echo "Committing changes..." +git add "$VERSION_FILE" +git commit -m "chore: update FirebaseAuthSwiftUI version" +echo -e "${GREEN}βœ“ Changes committed${NC}" +echo "" + +# Create annotated tag +echo "Creating annotated tag $NEW_TAG..." +git tag -a "$NEW_TAG" -m "Release $NEW_TAG" +echo -e "${GREEN}βœ“ Tag created${NC}" +echo "" + +if [ "$DRY_RUN" = true ]; then + echo -e "${YELLOW}DRY RUN: Skipping push operations${NC}" + echo "" + echo "Would push:" + echo " - Commit to origin/main" + echo " - Tag $NEW_TAG to origin" + echo "" + echo -e "${YELLOW}Cleaning up (removing commit and tag)...${NC}" + git tag -d "$NEW_TAG" + git reset --soft HEAD~1 + git restore --staged "$VERSION_FILE" + echo -e "${GREEN}βœ“ Local changes cleaned up${NC}" + echo "" + echo -e "${GREEN}=== Dry Run Complete ===${NC}" + echo "Version: $NEW_VERSION" + echo "Tag: $NEW_TAG" + echo "" + echo "Everything looks good! Run without --dry-run to actually release." +else + # Push commit + echo "Pushing commit to remote..." + git push origin main + echo -e "${GREEN}βœ“ Commit pushed${NC}" + echo "" + + # Push tag + echo "Pushing tag to remote..." + git push origin "$NEW_TAG" + echo -e "${GREEN}βœ“ Tag pushed${NC}" + echo "" + + echo -e "${GREEN}=== Release Complete ===${NC}" + echo "Version: $NEW_VERSION" + echo "Tag: $NEW_TAG" + echo "" + echo "Next steps:" + echo "1. Verify the tag on GitHub: https://github.com/firebase/FirebaseUI-iOS/releases" + echo "2. Create release notes if needed" +fi + diff --git a/samples/swift/FirebaseUI-demo-swift.xcodeproj/project.pbxproj b/samples/swift/FirebaseUI-demo-swift.xcodeproj/project.pbxproj index dabd26609d..d236027bae 100644 --- a/samples/swift/FirebaseUI-demo-swift.xcodeproj/project.pbxproj +++ b/samples/swift/FirebaseUI-demo-swift.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 4617B75BF5701E48387F35F6 /* Pods_FirebaseUI_demo_swift.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 25F197CFBB06559F4B537E37 /* Pods_FirebaseUI_demo_swift.framework */; }; + 6096FEF87E5B53C0792BC146 /* Pods_FirebaseUI_demo_swiftTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 637019691D9C301621749DE1 /* Pods_FirebaseUI_demo_swiftTests.framework */; }; 89B2924722568B1C00CEF7D7 /* twtrsymbol.png in Resources */ = {isa = PBXBuildFile; fileRef = 89B2924622568B1C00CEF7D7 /* twtrsymbol.png */; }; 8D5F93B01D9B192D00D5A2E4 /* StorageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D5F93AF1D9B192D00D5A2E4 /* StorageViewController.swift */; }; 8DABC9891D3D82D600453807 /* FUIAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DABC9881D3D82D600453807 /* FUIAppDelegate.swift */; }; @@ -49,6 +51,9 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 25F197CFBB06559F4B537E37 /* Pods_FirebaseUI_demo_swift.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_FirebaseUI_demo_swift.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 637019691D9C301621749DE1 /* Pods_FirebaseUI_demo_swiftTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_FirebaseUI_demo_swiftTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 6F5993EFB11CBA0003C0DE94 /* Pods-FirebaseUI-demo-swiftTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FirebaseUI-demo-swiftTests.release.xcconfig"; path = "Target Support Files/Pods-FirebaseUI-demo-swiftTests/Pods-FirebaseUI-demo-swiftTests.release.xcconfig"; sourceTree = ""; }; 89B2924622568B1C00CEF7D7 /* twtrsymbol.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = twtrsymbol.png; sourceTree = ""; }; 8D5F93AF1D9B192D00D5A2E4 /* StorageViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StorageViewController.swift; sourceTree = ""; }; 8DABC9851D3D82D600453807 /* FirebaseUI-demo-swift.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FirebaseUI-demo-swift.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -59,6 +64,8 @@ 8DABC99D1D3D82D600453807 /* FirebaseUI-demo-swiftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FirebaseUI-demo-swiftTests.swift"; sourceTree = ""; }; 8DABC99F1D3D82D600453807 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 8DD51E361D873B0D00E2CA51 /* UIStoryboardExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIStoryboardExtension.swift; sourceTree = ""; }; + 8E009A2D4461F77B9CEB0C4D /* Pods-FirebaseUI-demo-swiftTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FirebaseUI-demo-swiftTests.debug.xcconfig"; path = "Target Support Files/Pods-FirebaseUI-demo-swiftTests/Pods-FirebaseUI-demo-swiftTests.debug.xcconfig"; sourceTree = ""; }; + A885F4D8D84B72ADACBE725B /* Pods-FirebaseUI-demo-swift.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FirebaseUI-demo-swift.release.xcconfig"; path = "Target Support Files/Pods-FirebaseUI-demo-swift/Pods-FirebaseUI-demo-swift.release.xcconfig"; sourceTree = ""; }; C302C1D51D91CC7B00ADBD41 /* FUIAuthViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FUIAuthViewController.swift; sourceTree = ""; }; C302C1D71D91CC7B00ADBD41 /* ChatCollectionViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatCollectionViewCell.swift; sourceTree = ""; }; C302C1D81D91CC7B00ADBD41 /* ChatViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatViewController.swift; sourceTree = ""; }; @@ -166,6 +173,7 @@ C39BC04F1DB812330060F6AF /* FUICustomPasswordVerificationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FUICustomPasswordVerificationViewController.swift; sourceTree = ""; }; C39BC0501DB812330060F6AF /* FUICustomPasswordVerificationViewController.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = FUICustomPasswordVerificationViewController.xib; sourceTree = ""; }; C3F23ECC1D80F3300020509F /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + DB206ACE5B8C8DC3A2E47E00 /* Pods-FirebaseUI-demo-swift.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FirebaseUI-demo-swift.debug.xcconfig"; path = "Target Support Files/Pods-FirebaseUI-demo-swift/Pods-FirebaseUI-demo-swift.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -173,6 +181,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 4617B75BF5701E48387F35F6 /* Pods_FirebaseUI_demo_swift.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -180,6 +189,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 6096FEF87E5B53C0792BC146 /* Pods_FirebaseUI_demo_swiftTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -202,6 +212,7 @@ 8DABC99C1D3D82D600453807 /* FirebaseUI-demo-swiftTests */, 8DABC9861D3D82D600453807 /* Products */, 9C43BF8CA810E7C909775084 /* Pods */, + C129AF2D5B3F8906D7A96042 /* Frameworks */, ); sourceTree = ""; }; @@ -240,10 +251,23 @@ 9C43BF8CA810E7C909775084 /* Pods */ = { isa = PBXGroup; children = ( + DB206ACE5B8C8DC3A2E47E00 /* Pods-FirebaseUI-demo-swift.debug.xcconfig */, + A885F4D8D84B72ADACBE725B /* Pods-FirebaseUI-demo-swift.release.xcconfig */, + 8E009A2D4461F77B9CEB0C4D /* Pods-FirebaseUI-demo-swiftTests.debug.xcconfig */, + 6F5993EFB11CBA0003C0DE94 /* Pods-FirebaseUI-demo-swiftTests.release.xcconfig */, ); path = Pods; sourceTree = ""; }; + C129AF2D5B3F8906D7A96042 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 25F197CFBB06559F4B537E37 /* Pods_FirebaseUI_demo_swift.framework */, + 637019691D9C301621749DE1 /* Pods_FirebaseUI_demo_swiftTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; C302C1D31D91CC7B00ADBD41 /* Samples */ = { isa = PBXGroup; children = ( @@ -305,9 +329,11 @@ isa = PBXNativeTarget; buildConfigurationList = 8DABC9A21D3D82D600453807 /* Build configuration list for PBXNativeTarget "FirebaseUI-demo-swift" */; buildPhases = ( + 3D86CE81C1F8711347A14B72 /* [CP] Check Pods Manifest.lock */, 8DABC9811D3D82D600453807 /* Sources */, 8DABC9821D3D82D600453807 /* Frameworks */, 8DABC9831D3D82D600453807 /* Resources */, + 04D211F7D3B42A6D19A9E000 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -322,6 +348,7 @@ isa = PBXNativeTarget; buildConfigurationList = 8DABC9A51D3D82D600453807 /* Build configuration list for PBXNativeTarget "FirebaseUI-demo-swiftTests" */; buildPhases = ( + 94F892B9CDD1C2428D7F724B /* [CP] Check Pods Manifest.lock */, 8DABC9951D3D82D600453807 /* Sources */, 8DABC9961D3D82D600453807 /* Frameworks */, 8DABC9971D3D82D600453807 /* Resources */, @@ -348,7 +375,6 @@ TargetAttributes = { 8DABC9841D3D82D600453807 = { CreatedOnToolsVersion = 7.3.1; - DevelopmentTeam = YYX2P3XVJ7; LastSwiftMigration = 1020; SystemCapabilities = { com.apple.BackgroundModes = { @@ -497,6 +523,149 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 04D211F7D3B42A6D19A9E000 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-FirebaseUI-demo-swift/Pods-FirebaseUI-demo-swift-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/AppAuth/AppAuth.framework", + "${BUILT_PRODUCTS_DIR}/AppCheckCore/AppCheckCore.framework", + "${BUILT_PRODUCTS_DIR}/BoringSSL-GRPC/openssl_grpc.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseAnonymousAuthUI/FirebaseAnonymousAuthUI.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseAppCheckInterop/FirebaseAppCheckInterop.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseAuth/FirebaseAuth.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseAuthInterop/FirebaseAuthInterop.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseAuthUI/FirebaseAuthUI.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseCore/FirebaseCore.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseCoreExtension/FirebaseCoreExtension.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseCoreInternal/FirebaseCoreInternal.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseDatabase/FirebaseDatabase.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseDatabaseUI/FirebaseDatabaseUI.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseEmailAuthUI/FirebaseEmailAuthUI.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseFacebookAuthUI/FirebaseFacebookAuthUI.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseFirestore/FirebaseFirestore.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseFirestoreInternal/FirebaseFirestoreInternal.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseFirestoreUI/FirebaseFirestoreUI.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseGoogleAuthUI/FirebaseGoogleAuthUI.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseOAuthUI/FirebaseOAuthUI.framework", + "${BUILT_PRODUCTS_DIR}/FirebasePhoneAuthUI/FirebasePhoneAuthUI.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseSharedSwift/FirebaseSharedSwift.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseStorage/FirebaseStorage.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseStorageUI/FirebaseStorageUI.framework", + "${BUILT_PRODUCTS_DIR}/GTMAppAuth/GTMAppAuth.framework", + "${BUILT_PRODUCTS_DIR}/GTMSessionFetcher/GTMSessionFetcher.framework", + "${BUILT_PRODUCTS_DIR}/GoogleSignIn/GoogleSignIn.framework", + "${BUILT_PRODUCTS_DIR}/GoogleUtilities/GoogleUtilities.framework", + "${BUILT_PRODUCTS_DIR}/PromisesObjC/FBLPromises.framework", + "${BUILT_PRODUCTS_DIR}/RecaptchaInterop/RecaptchaInterop.framework", + "${BUILT_PRODUCTS_DIR}/SDWebImage/SDWebImage.framework", + "${BUILT_PRODUCTS_DIR}/abseil/absl.framework", + "${BUILT_PRODUCTS_DIR}/gRPC-C++/grpcpp.framework", + "${BUILT_PRODUCTS_DIR}/gRPC-Core/grpc.framework", + "${BUILT_PRODUCTS_DIR}/leveldb-library/leveldb.framework", + "${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/FBAEMKit/FBAEMKit.framework/FBAEMKit", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/FBSDKCoreKit/FBSDKCoreKit.framework/FBSDKCoreKit", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/FBSDKCoreKit_Basics/FBSDKCoreKit_Basics.framework/FBSDKCoreKit_Basics", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/FBSDKLoginKit/FBSDKLoginKit.framework/FBSDKLoginKit", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/AppAuth.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/AppCheckCore.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/openssl_grpc.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseAnonymousAuthUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseAppCheckInterop.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseAuth.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseAuthInterop.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseAuthUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseCore.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseCoreExtension.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseCoreInternal.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseDatabase.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseDatabaseUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseEmailAuthUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseFacebookAuthUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseFirestore.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseFirestoreInternal.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseFirestoreUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseGoogleAuthUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseOAuthUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebasePhoneAuthUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseSharedSwift.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseStorage.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseStorageUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GTMAppAuth.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GTMSessionFetcher.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleSignIn.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleUtilities.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FBLPromises.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RecaptchaInterop.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImage.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/absl.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/grpcpp.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/grpc.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/leveldb.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FBAEMKit.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FBSDKCoreKit.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FBSDKCoreKit_Basics.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FBSDKLoginKit.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-FirebaseUI-demo-swift/Pods-FirebaseUI-demo-swift-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 3D86CE81C1F8711347A14B72 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-FirebaseUI-demo-swift-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 94F892B9CDD1C2428D7F724B /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-FirebaseUI-demo-swiftTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 8DABC9811D3D82D600453807 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -748,12 +917,13 @@ }; 8DABC9A31D3D82D600453807 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = DB206ACE5B8C8DC3A2E47E00 /* Pods-FirebaseUI-demo-swift.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; BITCODE_GENERATION_MODE = ""; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "FirebaseUI-demo-swift/FirebaseUI-demo-swift.entitlements"; - DEVELOPMENT_TEAM = YYX2P3XVJ7; + DEVELOPMENT_TEAM = ""; HEADER_SEARCH_PATHS = ( "$(inherited)", "${PODS_ROOT}/Firebase/Core/Sources", @@ -792,7 +962,7 @@ BITCODE_GENERATION_MODE = ""; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "FirebaseUI-demo-swift/FirebaseUI-demo-swift.entitlements"; - DEVELOPMENT_TEAM = YYX2P3XVJ7; + DEVELOPMENT_TEAM = ""; HEADER_SEARCH_PATHS = ( "$(inherited)", "${PODS_ROOT}/Firebase/Core/Sources", diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/project.pbxproj b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/project.pbxproj index 812f7b770c..7b274a794d 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/project.pbxproj +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/project.pbxproj @@ -11,6 +11,9 @@ 4600E5542DD777BE00EED5F3 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4600E5532DD777BE00EED5F3 /* FirebaseCore */; }; 4607CC9C2D9BFE29009EC3F5 /* FirebaseAuthSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 4607CC9B2D9BFE29009EC3F5 /* FirebaseAuthSwiftUI */; }; 4607CC9E2D9BFE29009EC3F5 /* FirebaseGoogleSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 4607CC9D2D9BFE29009EC3F5 /* FirebaseGoogleSwiftUI */; }; + 4610DD2A2EA796360084B32B /* FirebaseAppleSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 4610DD292EA796360084B32B /* FirebaseAppleSwiftUI */; }; + 464938E92EA8E6BD0013A9E3 /* FirebaseOAuthSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 464938E82EA8E6BD0013A9E3 /* FirebaseOAuthSwiftUI */; }; + 4681E0002E97F22B00387C88 /* FirebaseTwitterSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 4681DFFF2E97F22B00387C88 /* FirebaseTwitterSwiftUI */; }; 46CB7B252D773F2100F1FD0A /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 46CB7B242D773F2100F1FD0A /* GoogleService-Info.plist */; }; 46F89C392D64B04E000F8BC0 /* FirebaseAuthSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 46F89C382D64B04E000F8BC0 /* FirebaseAuthSwiftUI */; }; 46F89C4D2D64BB9B000F8BC0 /* FirebaseAuthSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 46F89C4C2D64BB9B000F8BC0 /* FirebaseAuthSwiftUI */; }; @@ -80,10 +83,13 @@ files = ( 8D808CB72DB0811900D2293F /* FirebaseFacebookSwiftUI in Frameworks */, 46F89C392D64B04E000F8BC0 /* FirebaseAuthSwiftUI in Frameworks */, + 4610DD2A2EA796360084B32B /* FirebaseAppleSwiftUI in Frameworks */, 46F89C4D2D64BB9B000F8BC0 /* FirebaseAuthSwiftUI in Frameworks */, 4607CC9E2D9BFE29009EC3F5 /* FirebaseGoogleSwiftUI in Frameworks */, 8D808CB92DB081F900D2293F /* FirebasePhoneAuthSwiftUI in Frameworks */, + 4681E0002E97F22B00387C88 /* FirebaseTwitterSwiftUI in Frameworks */, 4607CC9C2D9BFE29009EC3F5 /* FirebaseAuthSwiftUI in Frameworks */, + 464938E92EA8E6BD0013A9E3 /* FirebaseOAuthSwiftUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -161,6 +167,9 @@ 4607CC9D2D9BFE29009EC3F5 /* FirebaseGoogleSwiftUI */, 8D808CB62DB0811900D2293F /* FirebaseFacebookSwiftUI */, 8D808CB82DB081F900D2293F /* FirebasePhoneAuthSwiftUI */, + 4681DFFF2E97F22B00387C88 /* FirebaseTwitterSwiftUI */, + 4610DD292EA796360084B32B /* FirebaseAppleSwiftUI */, + 464938E82EA8E6BD0013A9E3 /* FirebaseOAuthSwiftUI */, ); productName = FirebaseSwiftUIExample; productReference = 46F89C082D64A86C000F8BC0 /* FirebaseSwiftUIExample.app */; @@ -453,7 +462,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"FirebaseSwiftUIExample/Preview Content\""; - DEVELOPMENT_TEAM = YYX2P3XVJ7; + DEVELOPMENT_TEAM = 3G33A99C47; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = FirebaseSwiftUIExample/Info.plist; @@ -469,10 +478,10 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.firebase.auth.example; + PRODUCT_BUNDLE_IDENTIFIER = aob.flutter.plugins.firebase.auth.example; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -486,7 +495,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"FirebaseSwiftUIExample/Preview Content\""; - DEVELOPMENT_TEAM = YYX2P3XVJ7; + DEVELOPMENT_TEAM = 3G33A99C47; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = FirebaseSwiftUIExample/Info.plist; @@ -502,10 +511,10 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.firebase.auth.example; + PRODUCT_BUNDLE_IDENTIFIER = aob.flutter.plugins.firebase.auth.example; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -523,7 +532,7 @@ PRODUCT_BUNDLE_IDENTIFIER = io.invertase.testing.FirebaseSwiftUIExampleTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FirebaseSwiftUIExample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/FirebaseSwiftUIExample"; }; @@ -542,7 +551,7 @@ PRODUCT_BUNDLE_IDENTIFIER = io.invertase.testing.FirebaseSwiftUIExampleTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FirebaseSwiftUIExample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/FirebaseSwiftUIExample"; }; @@ -559,7 +568,7 @@ PRODUCT_BUNDLE_IDENTIFIER = io.invertase.testing.FirebaseSwiftUIExampleUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = FirebaseSwiftUIExample; }; @@ -576,7 +585,7 @@ PRODUCT_BUNDLE_IDENTIFIER = io.invertase.testing.FirebaseSwiftUIExampleUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = FirebaseSwiftUIExample; }; @@ -660,6 +669,21 @@ isa = XCSwiftPackageProductDependency; productName = FirebaseGoogleSwiftUI; }; + 4610DD292EA796360084B32B /* FirebaseAppleSwiftUI */ = { + isa = XCSwiftPackageProductDependency; + package = 8D808CB52DB07EBD00D2293F /* XCLocalSwiftPackageReference "../../../../FirebaseUI-iOS" */; + productName = FirebaseAppleSwiftUI; + }; + 464938E82EA8E6BD0013A9E3 /* FirebaseOAuthSwiftUI */ = { + isa = XCSwiftPackageProductDependency; + package = 8D808CB52DB07EBD00D2293F /* XCLocalSwiftPackageReference "../../../../FirebaseUI-iOS" */; + productName = FirebaseOAuthSwiftUI; + }; + 4681DFFF2E97F22B00387C88 /* FirebaseTwitterSwiftUI */ = { + isa = XCSwiftPackageProductDependency; + package = 8D808CB52DB07EBD00D2293F /* XCLocalSwiftPackageReference "../../../../FirebaseUI-iOS" */; + productName = FirebaseTwitterSwiftUI; + }; 46F89C382D64B04E000F8BC0 /* FirebaseAuthSwiftUI */ = { isa = XCSwiftPackageProductDependency; productName = FirebaseAuthSwiftUI; diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/xcshareddata/xcschemes/FirebaseSwiftUIExample.xcscheme b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/xcshareddata/xcschemes/FirebaseSwiftUIExample.xcscheme index 30faacec20..bbe3e1cad5 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/xcshareddata/xcschemes/FirebaseSwiftUIExample.xcscheme +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample.xcodeproj/xcshareddata/xcschemes/FirebaseSwiftUIExample.xcscheme @@ -31,7 +31,7 @@ shouldAutocreateTestPlan = "YES"> Bool { FirebaseApp.configure() - if uiAuthEmulator { - Auth.auth().useEmulator(withHost: "localhost", port: 9099) - } ApplicationDelegate.shared.application( application, @@ -58,6 +55,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + if Auth.auth().canHandle(url) { return true } + if ApplicationDelegate.shared.application( app, open: url, @@ -76,15 +75,13 @@ class AppDelegate: NSObject, UIApplicationDelegate { struct FirebaseSwiftUIExampleApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - init() { - Task { - try await testCreateUser() - } - } + init() {} var body: some Scene { WindowGroup { - NavigationView { + if testRunner { + TestView() + } else { ContentView() } } diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Assets.xcassets/ic-line-logo.imageset/Contents.json b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Assets.xcassets/ic-line-logo.imageset/Contents.json new file mode 100644 index 0000000000..216d35cbec --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Assets.xcassets/ic-line-logo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fui-ic-line-logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fui-ic-line-logo-x2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fui-ic-line-logo-x3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Assets.xcassets/ic-line-logo.imageset/fui-ic-line-logo-x2.png b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Assets.xcassets/ic-line-logo.imageset/fui-ic-line-logo-x2.png new file mode 100644 index 0000000000..924fd4adbc Binary files /dev/null and b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Assets.xcassets/ic-line-logo.imageset/fui-ic-line-logo-x2.png differ diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Assets.xcassets/ic-line-logo.imageset/fui-ic-line-logo-x3.png b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Assets.xcassets/ic-line-logo.imageset/fui-ic-line-logo-x3.png new file mode 100644 index 0000000000..77131141d7 Binary files /dev/null and b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Assets.xcassets/ic-line-logo.imageset/fui-ic-line-logo-x3.png differ diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Assets.xcassets/ic-line-logo.imageset/fui-ic-line-logo.png b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Assets.xcassets/ic-line-logo.imageset/fui-ic-line-logo.png new file mode 100644 index 0000000000..3e58a91d7f Binary files /dev/null and b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Assets.xcassets/ic-line-logo.imageset/fui-ic-line-logo.png differ diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Assets.xcassets/line-button.colorset/Contents.json b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Assets.xcassets/line-button.colorset/Contents.json new file mode 100644 index 0000000000..f3dcfcaa71 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Assets.xcassets/line-button.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x55", + "green" : "0xC7", + "red" : "0x06" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x55", + "green" : "0xC7", + "red" : "0x06" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExample.entitlements b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExample.entitlements index ea83d33fa9..f817dae27e 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExample.entitlements +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExample.entitlements @@ -4,6 +4,10 @@ aps-environment development + com.apple.developer.applesignin + + Default + com.apple.developer.associated-domains applinks:flutterfire-e2e-tests.firebaseapp.com diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Info.plist b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Info.plist index 968fe3cff8..e5d2f1e122 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Info.plist +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Info.plist @@ -29,6 +29,8 @@ 16dbbdf0cfb309034a6ad98ac2a21688 FacebookDisplayName Firebase Swift UI App + FirebaseAppDelegateProxyEnabled + LSApplicationQueriesSchemes fbapi @@ -39,7 +41,5 @@ fetch remote-notification - FirebaseAppDelegateProxyEnabled - diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/TestView.swift similarity index 67% rename from samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift rename to samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/TestView.swift index e51ef876cd..f531419301 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/TestView.swift @@ -19,18 +19,27 @@ // Created by Russell Wheatley on 23/04/2025. // +import FirebaseAppleSwiftUI import FirebaseAuth import FirebaseAuthSwiftUI import FirebaseFacebookSwiftUI import FirebaseGoogleSwiftUI +import FirebaseOAuthSwiftUI import FirebasePhoneAuthSwiftUI +import FirebaseTwitterSwiftUI import SwiftUI -struct ContentView: View { +struct TestView: View { let authService: AuthService init() { - Auth.auth().signInAnonymously() + Auth.auth().useEmulator(withHost: "localhost", port: 9099) + Auth.auth().settings?.isAppVerificationDisabledForTesting = true + Task { + try signOut() + } + + let isMfaEnabled = ProcessInfo.processInfo.arguments.contains("--mfa-enabled") let actionCodeSettings = ActionCodeSettings() actionCodeSettings.handleCodeInApp = true @@ -39,10 +48,10 @@ struct ContentView: View { actionCodeSettings.linkDomain = "flutterfire-e2e-tests.firebaseapp.com" actionCodeSettings.setIOSBundleID(Bundle.main.bundleIdentifier!) let configuration = AuthConfiguration( - shouldAutoUpgradeAnonymousUsers: !uiAuthEmulator, tosUrl: URL(string: "https://example.com/tos"), privacyPolicyUrl: URL(string: "https://example.com/privacy"), - emailLinkSignInActionCodeSettings: actionCodeSettings + emailLinkSignInActionCodeSettings: actionCodeSettings, + mfaEnabled: isMfaEnabled ) authService = AuthService( @@ -50,11 +59,20 @@ struct ContentView: View { ) .withGoogleSignIn() .withPhoneSignIn() + .withAppleSignIn() + .withTwitterSignIn() + .withOAuthSignIn(OAuthProviderSwift.github()) + .withOAuthSignIn(OAuthProviderSwift.microsoft()) + .withOAuthSignIn(OAuthProviderSwift.yahoo()) .withFacebookSignIn() .withEmailSignIn() + authService.isPresented = true } var body: some View { - AuthPickerView().environment(authService) + AuthPickerView { + Text("Hello, world!") + } + .environment(authService) } } diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/UITestUtils.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/UITestUtils.swift index 2116c2f26a..0f2f3713ee 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/UITestUtils.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/UITestUtils.swift @@ -8,20 +8,8 @@ import FirebaseAuth import SwiftUI // UI Test Runner keys -public let uiAuthEmulator = CommandLine.arguments.contains("--auth-emulator") +public let testRunner = CommandLine.arguments.contains("--test-view-enabled") -public var testEmail: String? { - guard let emailIndex = CommandLine.arguments.firstIndex(of: "--create-user"), - CommandLine.arguments.indices.contains(emailIndex + 1) - else { return nil } - return CommandLine.arguments[emailIndex + 1] -} - -func testCreateUser() async throws { - if let email = testEmail { - let password = "123456" - let auth = Auth.auth() - try await auth.createUser(withEmail: email, password: password) - try auth.signOut() - } +func signOut() throws { + try Auth.auth().signOut() } diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/start-firebase-emulator.sh b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/start-firebase-emulator.sh index fb1280e438..d5ee3dec67 100755 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/start-firebase-emulator.sh +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/start-firebase-emulator.sh @@ -14,7 +14,7 @@ if ! [ -x "$(command -v npm)" ]; then exit 1 fi -EMU_START_COMMAND="firebase emulators:start --only auth --project flutterfire-e2e-tests" +EMU_START_COMMAND="firebase emulators:start --only auth --project flutterfire-e2e-tests --debug" MAX_RETRIES=3 MAX_CHECKATTEMPTS=60 diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift index e43a002927..408576e12a 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift @@ -101,18 +101,22 @@ struct FirebaseSwiftUIExampleTests { let service = try await prepareFreshAuthService() #expect(service.authenticationState == .unauthenticated) - #expect(service.authView == .authPicker) - #expect(service.errorMessage.isEmpty) - #expect(service.signedInCredential == nil) + #expect(service.authView == nil) + #expect(service.currentError == nil) #expect(service.currentUser == nil) - try await service.createUser(withEmail: createEmail(), password: kPassword) - try await Task.sleep(nanoseconds: 4_000_000_000) + try await service.createUser(email: createEmail(), password: kPassword) + + try await waitForStateChange { + service.authenticationState == .authenticated + } #expect(service.authenticationState == .authenticated) - #expect(service.authView == .authPicker) - #expect(service.errorMessage.isEmpty) + + try await waitForStateChange { + service.currentUser != nil + } #expect(service.currentUser != nil) - // TODO: - reinstate once this PR is merged: https://github.com/firebase/FirebaseUI-iOS/pull/1256 -// #expect(service.signedInCredential is AuthCredential) + #expect(service.authView == nil) + #expect(service.currentError == nil) } @Test @@ -120,22 +124,33 @@ struct FirebaseSwiftUIExampleTests { func testSignInUser() async throws { let service = try await prepareFreshAuthService() let email = createEmail() - try await service.createUser(withEmail: email, password: kPassword) + try await service.createUser(email: email, password: kPassword) try await service.signOut() - try await Task.sleep(nanoseconds: 2_000_000_000) + + try await waitForStateChange { + service.authenticationState == .unauthenticated + } #expect(service.authenticationState == .unauthenticated) - #expect(service.authView == .authPicker) - #expect(service.errorMessage.isEmpty) - #expect(service.signedInCredential == nil) + + try await waitForStateChange { + service.currentUser == nil + } #expect(service.currentUser == nil) + #expect(service.authView == nil) + #expect(service.currentError == nil) - try await service.signIn(withEmail: email, password: kPassword) + try await service.signIn(email: email, password: kPassword) + try await waitForStateChange { + service.authenticationState == .authenticated + } #expect(service.authenticationState == .authenticated) - #expect(service.authView == .authPicker) - #expect(service.errorMessage.isEmpty) + + try await waitForStateChange { + service.currentUser != nil + } #expect(service.currentUser != nil) - // TODO: - reinstate once this PR is merged: https://github.com/firebase/FirebaseUI-iOS/pull/1256 - // #expect(service.signedInCredential is AuthCredential) + #expect(service.authView == nil) + #expect(service.currentError == nil) } } diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift index 1b474daf80..7645e56d03 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift @@ -15,6 +15,7 @@ func configureFirebaseIfNeeded() { } } +@MainActor private var hasCheckedEmulatorAvailability = false @MainActor @@ -61,3 +62,21 @@ func createEmail() -> String { let after = UUID().uuidString.prefix(6) return "\(before)@\(after).com" } + +@MainActor +func waitForStateChange(timeout: TimeInterval = 10.0, + condition: @escaping () -> Bool) async throws { + let startTime = Date() + + while !condition() { + if Date().timeIntervalSince(startTime) > timeout { + throw TestError.timeout("Timeout waiting for condition to be met") + } + + try await Task.sleep(nanoseconds: 50_000_000) // 50ms + } +} + +enum TestError: Error { + case timeout(String) +} diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift index 1252d35519..b824d7d1bc 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/FirebaseSwiftUIExampleUITests.swift @@ -19,22 +19,8 @@ // Created by Russell Wheatley on 18/02/2025. // -import FirebaseAuth -import FirebaseCore import XCTest -func createEmail() -> String { - let before = UUID().uuidString.prefix(8) - let after = UUID().uuidString.prefix(6) - return "\(before)@\(after).com" -} - -func dismissAlert(app: XCUIApplication) { - if app.scrollViews.otherElements.buttons["Not Now"].waitForExistence(timeout: 2) { - app.scrollViews.otherElements.buttons["Not Now"].tap() - } -} - final class FirebaseSwiftUIExampleUITests: XCTestCase { override func setUpWithError() throws { continueAfterFailure = false @@ -59,11 +45,15 @@ final class FirebaseSwiftUIExampleUITests: XCTestCase { @MainActor func testSignInDisplaysSignedInView() async throws { - let app = XCUIApplication() let email = createEmail() - app.launchArguments.append("--auth-emulator") - app.launchArguments.append("--create-user") - app.launchArguments.append("\(email)") + let password = "123456" + + // Create user in test runner BEFORE launching app + // User will exist in emulator, but app starts unauthenticated + try await createTestUser(email: email, password: password) + + // Now launch the app - it connects to emulator but isn't signed in + let app = createTestApp() app.launch() let emailField = app.textFields["email-field"] @@ -74,15 +64,16 @@ final class FirebaseSwiftUIExampleUITests: XCTestCase { let passwordField = app.secureTextFields["password-field"] XCTAssertTrue(passwordField.exists, "Password field should exist") passwordField.tap() - passwordField.typeText("123456") + passwordField.typeText(password) let signInButton = app.buttons["sign-in-button"] XCTAssertTrue(signInButton.exists, "Sign-In button should exist") signInButton.tap() + // Wait for authentication to complete and signed-in view to appear let signedInText = app.staticTexts["signed-in-text"] XCTAssertTrue( - signedInText.waitForExistence(timeout: 10), + signedInText.waitForExistence(timeout: 30), "SignedInView should be visible after login" ) @@ -103,13 +94,14 @@ final class FirebaseSwiftUIExampleUITests: XCTestCase { let passwordRecoveryButton = app.buttons["password-recovery-button"] XCTAssertTrue(passwordRecoveryButton.exists, "Password recovery button should exist") passwordRecoveryButton.tap() - let passwordRecoveryText = app.staticTexts["password-recovery-text"] + let passwordRecoveryText = app.staticTexts["Send a password recovery link to your email"] + .firstMatch XCTAssertTrue( passwordRecoveryText.waitForExistence(timeout: 10), "Password recovery text should exist after routing to PasswordRecoveryView" ) - let passwordRecoveryBackButton = app.buttons["password-recovery-back-button"] + let passwordRecoveryBackButton = app.navigationBars.buttons.element(boundBy: 0) XCTAssertTrue(passwordRecoveryBackButton.exists, "Password back button should exist") passwordRecoveryBackButton.tap() @@ -123,14 +115,14 @@ final class FirebaseSwiftUIExampleUITests: XCTestCase { XCTAssertTrue(emailLinkSignInButton.exists, "Email link sign-in button should exist") emailLinkSignInButton.tap() - let emailLinkText = app.staticTexts["email-link-title-text"] + let emailLinkText = app.staticTexts["Send a sign-in link to your email"].firstMatch XCTAssertTrue( emailLinkText.waitForExistence(timeout: 10), "Email link text should exist after pressing email link button in AuthPickerView" ) - let emailLinkBackButton = app.buttons["email-link-back-button"] + let emailLinkBackButton = app.navigationBars.buttons.element(boundBy: 0) XCTAssertTrue(emailLinkBackButton.exists, "Email link back button should exist") emailLinkBackButton.tap() @@ -143,12 +135,17 @@ final class FirebaseSwiftUIExampleUITests: XCTestCase { @MainActor func testCreateUserDisplaysSignedInView() throws { - let app = XCUIApplication() let email = createEmail() let password = "qwerty321@" - app.launchArguments.append("--auth-emulator") + let app = createTestApp() app.launch() + // Check the Views are updated + let signOutButton = app.buttons["sign-out-button"] + if signOutButton.exists { + signOutButton.tap() + } + let switchFlowButton = app.buttons["switch-auth-flow"] switchFlowButton.tap() @@ -172,14 +169,26 @@ final class FirebaseSwiftUIExampleUITests: XCTestCase { confirmPasswordField.press(forDuration: 1.2) app.menuItems["Paste"].tap() - let signInButton = app.buttons["sign-in-button"] - XCTAssertTrue(signInButton.exists, "Sign-In button should exist") - signInButton.tap() + // Create the user (sign up) + let signUpButton = app + .buttons["sign-in-button"] // This button changes context after switch-auth-flow + XCTAssertTrue(signUpButton.exists, "Sign-Up button should exist") + signUpButton.tap() + + // Wait for the auth screen to disappear (email field should no longer exist) + let emailFieldDisappeared = NSPredicate(format: "exists == false") + let expectation = XCTNSPredicateExpectation( + predicate: emailFieldDisappeared, + object: emailField + ) + let result = XCTWaiter().wait(for: [expectation], timeout: 10.0) + XCTAssertEqual(result, .completed, "Email field should disappear after sign-up") + // Wait for user creation and signed-in view to appear let signedInText = app.staticTexts["signed-in-text"] XCTAssertTrue( - signedInText.waitForExistence(timeout: 20), - "SignedInView should be visible after login" + signedInText.waitForExistence(timeout: 30), + "SignedInView should be visible after user creation" ) } } diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift new file mode 100644 index 0000000000..1b8c981f03 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAEnrolmentUITests.swift @@ -0,0 +1,526 @@ +// 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. + +// +// MFAEnrollmentUITests.swift +// FirebaseSwiftUIExampleUITests +// +// UI tests for MFA enrollment workflows including SMS and TOTP enrollment +// + +import XCTest + +final class MFAEnrollmentUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + // MARK: - MFA Management Navigation Tests + + @MainActor + func testMFAManagementButtonExistsAndIsTappable() async throws { + let email = createEmail() + + // Create user in test runner before launching app + try await createTestUser(email: email) + + let app = createTestApp(mfaEnabled: true) + app.launch() + + // Sign in first to access MFA management + try signInToApp(app: app, email: email) + + // Check MFA management button exists + let mfaManagementButton = app.buttons["mfa-management-button"] + XCTAssertTrue( + mfaManagementButton.waitForExistence(timeout: 5), + "MFA management button should exist" + ) + XCTAssertTrue(mfaManagementButton.isEnabled, "MFA management button should be enabled") + + // Tap the button + mfaManagementButton.tap() + + // Verify we navigated to MFA management view + let managementTitle = app.staticTexts["Two-Factor Authentication"] + XCTAssertTrue( + managementTitle.waitForExistence(timeout: 5), + "Should navigate to MFA management view" + ) + } + + @MainActor + func testMFAEnrollmentNavigationFromManagement() async throws { + let email = createEmail() + + // Create user in test runner before launching app + try await createTestUser(email: email) + + let app = createTestApp(mfaEnabled: true) + app.launch() + + // Sign in and navigate to MFA management + try signInToApp(app: app, email: email) + app.buttons["mfa-management-button"].tap() + + // Tap setup MFA button (for users with no enrolled factors) + let setupButton = app.buttons["setup-mfa-button"] + if setupButton.waitForExistence(timeout: 3) { + setupButton.tap() + } else { + // If factors are already enrolled, tap add another method + let addMethodButton = app.buttons["add-mfa-method-button"] + XCTAssertTrue(addMethodButton.waitForExistence(timeout: 3), "Add method button should exist") + addMethodButton.tap() + } + + // Verify we navigated to MFA enrollment view + let enrollmentTitle = app.staticTexts["Set Up Two-Factor Authentication"] + XCTAssertTrue( + enrollmentTitle.waitForExistence(timeout: 5), + "Should navigate to MFA enrollment view" + ) + } + + // MARK: - MFA Enrollment Factor Selection Tests + + @MainActor + func testFactorTypePickerExistsAndWorks() async throws { + let email = createEmail() + + // Create user in test runner before launching app + try await createTestUser(email: email) + + let app = createTestApp(mfaEnabled: true) + app.launch() + + // Navigate to MFA enrollment + try signInToApp(app: app, email: email) + try navigateToMFAEnrollment(app: app) + + // Check factor type picker exists + let factorPicker = app.segmentedControls["factor-type-picker"] + XCTAssertTrue(factorPicker.waitForExistence(timeout: 5), "Factor type picker should exist") + + // Test selecting SMS + let smsOption = factorPicker.buttons.element(boundBy: 0) + smsOption.tap() + XCTAssertTrue(smsOption.isSelected, "SMS option should be selected") + + // Test selecting TOTP + let totpOption = factorPicker.buttons.element(boundBy: 1) + totpOption.tap() + XCTAssertTrue(totpOption.isSelected, "TOTP option should be selected") + } + + @MainActor + func testStartEnrollmentButtonExistsAndWorks() async throws { + let email = createEmail() + + // Create user in test runner before launching app + try await createTestUser(email: email) + + let app = createTestApp(mfaEnabled: true) + app.launch() + + // Navigate to MFA enrollment + try signInToApp(app: app, email: email) + try navigateToMFAEnrollment(app: app) + + // Check start enrollment button exists and is enabled + let startButton = app.buttons["start-enrollment-button"] + XCTAssertTrue(startButton.waitForExistence(timeout: 5), "Start enrollment button should exist") + XCTAssertTrue(startButton.isEnabled, "Start enrollment button should be enabled") + + // Tap the button + startButton.tap() + + // Verify the form changes (either phone input for SMS or QR code for TOTP) + let phoneField = app.textFields["phone-number-field"] + let qrCode = app.images["qr-code-image"] + + // Either phone field or QR code should appear + let phoneFieldExists = phoneField.waitForExistence(timeout: 5) + let qrCodeExists = qrCode.waitForExistence(timeout: 5) + + XCTAssertTrue( + phoneFieldExists || qrCodeExists, + "Either phone field or QR code should appear after starting enrollment" + ) + } + + // MARK: - SMS Enrollment Flow Tests + + @MainActor + func testEndToEndSMSEnrollmentAndRemovalFlow() async throws { + // 1) Create user in test runner before launching app (with email verification) + let email = createEmail() + try await createTestUser(email: email, verifyEmail: true) + + let app = createTestApp(mfaEnabled: true) + app.launch() + + // 2) Sign in to reach SignedInView + try signInToApp(app: app, email: email) + + // 3) From SignedInView, open MFA Management + let mfaManagementButton = app.buttons["mfa-management-button"] + XCTAssertTrue(mfaManagementButton.waitForExistence(timeout: 10)) + mfaManagementButton.tap() + + // 4) In MFAManagementView, tap "Set Up Two-Factor Authentication" + let setupButton = app.buttons["setup-mfa-button"] + XCTAssertTrue(setupButton.waitForExistence(timeout: 10)) + setupButton.tap() + + // 5) In MFAEnrollmentView, select SMS factor and start the flow + let factorPicker = app.segmentedControls["factor-type-picker"] + XCTAssertTrue(factorPicker.waitForExistence(timeout: 10)) + factorPicker.buttons.element(boundBy: 0).tap() // SMS + + let startButton = app.buttons["start-enrollment-button"] + XCTAssertTrue(startButton.waitForExistence(timeout: 10)) + startButton.tap() + + // 6) Select UK country code and enter phone number (without dial code) + // Find and tap the country selector - try multiple approaches since it's embedded in the + // TextField + let countrySelector = app.buttons["πŸ‡ΊπŸ‡Έ +1"] + XCTAssertTrue(countrySelector.waitForExistence(timeout: 5)) + + countrySelector.tap() + + // Select United Kingdom (+44) - try multiple element types + let ukOption = app.buttons["country-option-GB"] + XCTAssertTrue(ukOption.waitForExistence(timeout: 5)) + + ukOption.tap() + + // Enter phone number (without dial code) + let phoneField = app.textFields["phone-number-field"] + XCTAssertTrue(phoneField.waitForExistence(timeout: 10)) + let phoneNumberWithoutDialCode = "7444555666" + UIPasteboard.general.string = phoneNumberWithoutDialCode + phoneField.tap() + phoneField.press(forDuration: 1.2) + app.menuItems["Paste"].tap() + + let displayNameField = app.textFields["display-name-field"] + XCTAssertTrue(displayNameField.waitForExistence(timeout: 10)) + UIPasteboard.general.string = "test user" + displayNameField.tap() + displayNameField.press(forDuration: 1.2) + app.menuItems["Paste"].tap() + + let sendCodeButton = app.buttons["send-sms-button"] + XCTAssertTrue(sendCodeButton.waitForExistence(timeout: 10)) + XCTAssertTrue(sendCodeButton.isEnabled) + sendCodeButton.tap() + + // 7) Retrieve verification code from the Auth Emulator and complete setup + let verificationCodeField = app.textFields["verification-code-field"] + XCTAssertTrue(verificationCodeField.waitForExistence(timeout: 15)) + + // Fetch the latest SMS verification code generated by the emulator for this phone number + // The emulator stores the full phone number with dial code + let fullPhoneNumber = "+44\(phoneNumberWithoutDialCode)" + let code = try await getLastSmsCode(specificPhone: fullPhoneNumber) + + UIPasteboard.general.string = code + verificationCodeField.tap() + verificationCodeField.press(forDuration: 1.2) + app.menuItems["Paste"].tap() + + // Test resend code button exists + let resendButton = app.buttons["resend-code-button"] + XCTAssertTrue(resendButton.exists, "Resend code button should exist") + + let completeSetupButton = app.buttons["complete-enrollment-button"] + XCTAssertTrue(completeSetupButton.waitForExistence(timeout: 10)) + XCTAssertTrue(completeSetupButton.isEnabled) + completeSetupButton.tap() + + // 8) Verify we've returned to SignedInView + let signedInText = app.staticTexts["signed-in-text"] + XCTAssertTrue(signedInText.waitForExistence(timeout: 15)) + + // 9) Open MFA Management again and verify SMS factor is enrolled + XCTAssertTrue(mfaManagementButton.waitForExistence(timeout: 10)) + mfaManagementButton.tap() + + let enrolledMethodsHeader = app.staticTexts["Enrolled Methods"] + XCTAssertTrue(enrolledMethodsHeader.waitForExistence(timeout: 10)) + + // Find a "Remove" button for any enrolled factor (identifier starts with "remove-factor-") + let removeButton = app.buttons.matching(NSPredicate( + format: "identifier BEGINSWITH %@", + "remove-factor-" + )).firstMatch + XCTAssertTrue(removeButton.waitForExistence(timeout: 10)) + + // 10) Remove the enrolled SMS factor and verify we're back to setup state + removeButton.tap() + + // After removal, the setup button should reappear for an empty list + XCTAssertTrue(setupButton.waitForExistence(timeout: 15)) + } + + // MARK: - TOTP Enrollment Flow Tests + + @MainActor + func testTOTPEnrollmentFlowUI() async throws { + let email = createEmail() + + // Create user in test runner before launching app (with email verification) + try await createTestUser(email: email, verifyEmail: true) + + let app = createTestApp(mfaEnabled: true) + app.launch() + + // Navigate to MFA enrollment and select TOTP + try signInToApp(app: app, email: email) + try navigateToMFAEnrollment(app: app) + + // Select TOTP factor type + let factorPicker = app.segmentedControls["factor-type-picker"] + factorPicker.buttons.element(boundBy: 1).tap() // TOTP option + + // Start enrollment + app.buttons["start-enrollment-button"].tap() + + // Test QR code image (might not load in test environment) + let qrCodeImage = app.images["qr-code-image"] + if qrCodeImage.waitForExistence(timeout: 5) { + XCTAssertTrue(qrCodeImage.exists, "QR code image should appear") + } + + // TOTP enrollment isn't testable via emulator, so this is commented out for the moment + // Test TOTP secret key display +// let secretKey = app.staticTexts["totp-secret-key"] + +// XCTAssertTrue(secretKey.waitForExistence(timeout: 5), "TOTP secret key should be displayed") +// +// // Test display name field +// let displayNameField = app.textFields["display-name-field"] +// XCTAssertTrue(displayNameField.exists, "Display name field should exist") +// +// // Test TOTP code input field +// let totpCodeField = app.textFields["totp-code-field"] +// XCTAssertTrue(totpCodeField.exists, "TOTP code field should exist") +// XCTAssertTrue(totpCodeField.isEnabled, "TOTP code field should be enabled") +// +// // Test complete enrollment button +// let completeButton = app.buttons["complete-enrollment-button"] +// XCTAssertTrue(completeButton.exists, "Complete enrollment button should exist") +// +// // Button should be disabled without code +// XCTAssertFalse(completeButton.isEnabled, "Complete button should be disabled without code") +// +// // Enter TOTP code +// totpCodeField.tap() +// totpCodeField.typeText("123456") +// +// // Button should be enabled with code +// XCTAssertTrue(completeButton.isEnabled, "Complete button should be enabled with code") + } + + // MARK: - Error Handling Tests + + @MainActor + func testErrorMessageDisplay() async throws { + let email = createEmail() + + // Create user in test runner before launching app + try await createTestUser(email: email) + + let app = createTestApp(mfaEnabled: true) + app.launch() + + // Navigate to MFA enrollment + try signInToApp(app: app, email: email) + try navigateToMFAEnrollment(app: app) + + // Start enrollment to trigger potential errors + app.buttons["start-enrollment-button"].tap() + } + + // MARK: - Navigation Tests + + @MainActor + func testBackButtonNavigation() async throws { + let email = createEmail() + + // Create user in test runner before launching app + try await createTestUser(email: email) + + let app = createTestApp(mfaEnabled: true) + app.launch() + + // Navigate to MFA enrollment + try signInToApp(app: app, email: email) + try navigateToMFAEnrollment(app: app) + + // Test back button exists + let backButton = app.navigationBars.buttons.element(boundBy: 0) + XCTAssertTrue(backButton.exists, "Back button should exist") + + // Tap cancel button + backButton.tap() + + // Should navigate back to manage MFA View + let signedInText = app.buttons["setup-mfa-button"] + XCTAssertTrue( + signedInText.waitForExistence(timeout: 5), + "Should navigate back to setup MFA view" + ) + } + + // MARK: - Helper Methods + + @MainActor + private func signInToApp(app: XCUIApplication, email: String) throws { + let password = "123456" + + // Fill email field + let emailField = app.textFields["email-field"] + XCTAssertTrue(emailField.waitForExistence(timeout: 10), "Email field should exist") + // Workaround for updating SecureFields with ConnectHardwareKeyboard enabled + UIPasteboard.general.string = email + emailField.press(forDuration: 1.2) + app.menuItems["Paste"].tap() + + // Fill password field + let passwordField = app.secureTextFields["password-field"] + XCTAssertTrue(passwordField.exists, "Password field should exist") + UIPasteboard.general.string = password + passwordField.press(forDuration: 1.2) + app.menuItems["Paste"].tap() + + // Create the user (sign up) + let signUpButton = app + .buttons["sign-in-button"] // This button changes context after switch-auth-flow + XCTAssertTrue(signUpButton.exists, "Sign-up button should exist") + signUpButton.tap() + + let notNowButton = app.scrollViews.containing(.button, identifier: "Not Now").firstMatch + if notNowButton.waitForExistence(timeout: 5) { + notNowButton.tap() + } + + // Wait for signed-in state + // Wait for signed-in state + let signedInText = app.staticTexts["signed-in-text"] + XCTAssertTrue( + signedInText.waitForExistence(timeout: 30), + "SignedInView should be visible after login" + ) + XCTAssertTrue(signedInText.exists, "SignedInView should be visible after login") + } + + @MainActor + private func navigateToMFAEnrollment(app: XCUIApplication) throws { + // Navigate to MFA management + app.buttons["mfa-management-button"].tap() + + // Navigate to MFA enrollment + let setupButton = app.buttons["setup-mfa-button"] + if setupButton.waitForExistence(timeout: 3) { + setupButton.tap() + } else { + let addMethodButton = app.buttons["add-mfa-method-button"] + XCTAssertTrue(addMethodButton.waitForExistence(timeout: 3), "Add method button should exist") + addMethodButton.tap() + } + + // Verify we're in MFA enrollment view + let enrollmentTitle = app.staticTexts["Set Up Two-Factor Authentication"] + XCTAssertTrue(enrollmentTitle.waitForExistence(timeout: 5), "Should be in MFA enrollment view") + } +} + +struct VerificationCodesResponse: Codable { + let verificationCodes: [VerificationCode]? +} + +struct VerificationCode: Codable { + let phoneNumber: String + let code: String +} + +/// Retrieves the last SMS verification code from Firebase Auth Emulator +/// - Parameter specificPhone: Optional phone number to filter codes for a specific phone +/// - Returns: The verification code as a String +/// - Throws: Error if unable to retrieve codes +private func getLastSmsCode(specificPhone: String? = nil) async throws -> String { + let getSmsCodesUrl = + "http://127.0.0.1:9099/emulator/v1/projects/flutterfire-e2e-tests/verificationCodes" + + guard let url = URL(string: getSmsCodesUrl) else { + throw NSError( + domain: "getLastSmsCode", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to create URL for SMS codes endpoint"] + ) + } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + + let decoder = JSONDecoder() + let codesResponse = try decoder.decode(VerificationCodesResponse.self, from: data) + + guard let codes = codesResponse.verificationCodes, !codes.isEmpty else { + throw NSError( + domain: "getLastSmsCode", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "No SMS verification codes found in emulator"] + ) + } + + if let specificPhone = specificPhone { + // Search backwards through codes for the specific phone number + for code in codes.reversed() { + if code.phoneNumber == specificPhone { + return code.code + } + } + throw NSError( + domain: "getLastSmsCode", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: "No SMS verification code found for phone number: \(specificPhone)", + ] + ) + } else { + // Return the last code in the array + return codes.last!.code + } + } catch let error as DecodingError { + throw NSError( + domain: "getLastSmsCode", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: "Failed to parse SMS codes response: \(error.localizedDescription)", + ] + ) + } catch { + throw NSError( + domain: "getLastSmsCode", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: "Network request failed: \(error.localizedDescription)", + ] + ) + } +} diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift new file mode 100644 index 0000000000..e1026b2663 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/MFAResolutionUITests.swift @@ -0,0 +1,512 @@ +// 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. + +// +// MFAResolutionUITests.swift +// FirebaseSwiftUIExampleUITests +// +// UI tests for MFA resolution workflows during sign-in +// + +import XCTest + +final class MFAResolutionUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + // MARK: - MFA Resolution UI Tests + + // MARK: - Complete MFA Resolution Flow + + @MainActor + func testCompleteMFAResolutionFlowWithAPIEnrollment() async throws { + let app = createTestApp(mfaEnabled: true) + app.launch() + + let email = createEmail() + let password = "12345678" + let phoneNumber = "+15551234567" + + // Sign up the user + try await signUpUser(email: email, password: password) + + // Get ID token and enable MFA via API + guard let idToken = await getIDTokenFromEmulator(email: email, password: password) else { + XCTFail("Failed to get ID token from emulator") + return + } + + try await verifyEmailInEmulator(email: email, idToken: idToken) + + let mfaEnabled = await enableSMSMFAViaEmulator( + idToken: idToken, + phoneNumber: phoneNumber, + displayName: "Test Phone" + ) + + XCTAssertTrue(mfaEnabled, "MFA should be enabled successfully via API") + + // Wait for sign out to complete + let emailField = app.textFields["email-field"] + XCTAssertTrue(emailField.waitForExistence(timeout: 10), "Should return to auth picker") + + try signInUser(app: app, email: email, password: password) + + let mfaResolutionTitle = app.staticTexts["mfa-resolution-title"] + XCTAssertTrue( + mfaResolutionTitle.waitForExistence(timeout: 10), + "MFA resolution view should appear" + ) + + let smsButton = app.buttons["sms-method-button"] + if smsButton.exists && smsButton.isEnabled { + smsButton.tap() + } + dismissAlert(app: app) + + // Wait for SMS to be sent + try await Task.sleep(nanoseconds: 2_000_000_000) + + let sendSMSButton = app.buttons["send-sms-button"] + + sendSMSButton.tap() + + try await Task.sleep(nanoseconds: 3_000_000_000) + + guard let verificationCode = await getSMSVerificationCode( + for: phoneNumber, + codeType: "verification" + ) else { + XCTFail("Failed to retrieve SMS verification code from emulator") + return + } + + let codeField = app.textFields["sms-verification-code-field"] + XCTAssertTrue(codeField.waitForExistence(timeout: 10), "Code field should exist") + codeField.tap() + codeField.typeText(verificationCode) + + let completeButton = app.buttons["complete-resolution-button"] + XCTAssertTrue(completeButton.exists, "Complete button should exist") + completeButton.tap() + + // Wait for sign-in to complete + // Resolution always fails due to ERROR_MULTI_FACTOR_INFO_NOT_FOUND exception. See below issue + // for more information. + // TODO(russellwheatley): uncomment below when this firebase-ios-sdk issue has been resolved: https://github.com/firebase/firebase-ios-sdk/issues/11079 + + // let signedInText = app.staticTexts["signed-in-text"] + // XCTAssertTrue( + // signedInText.waitForExistence(timeout: 10), + // "User should be signed in after MFA resolution" + // ) + } + + // MARK: - Helper Methods + + /// Programmatically enables SMS MFA for a user via the Auth emulator REST API + /// - Parameters: + /// - idToken: The user's Firebase ID token + /// - phoneNumber: The phone number to enroll for SMS MFA (e.g., "+15551234567") + /// - displayName: Optional display name for the MFA factor + /// - Returns: True if MFA was successfully enabled, false otherwise + @MainActor + private func enableSMSMFAViaEmulator(idToken: String, + phoneNumber: String, + displayName: String = "Test Phone") async -> Bool { + let emulatorUrl = + "http://127.0.0.1:9099/identitytoolkit.googleapis.com/v2/accounts/mfaEnrollment:start?key=fake-api-key" + + guard let url = URL(string: emulatorUrl) else { + XCTFail("Invalid emulator URL") + return false + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let requestBody: [String: Any] = [ + "idToken": idToken, + "phoneEnrollmentInfo": [ + "phoneNumber": phoneNumber, + "recaptchaToken": "fake-recaptcha-token", + ], + ] + + guard let httpBody = try? JSONSerialization.data(withJSONObject: requestBody) else { + XCTFail("Failed to serialize request body") + return false + } + + request.httpBody = httpBody + + // Step 1: Start MFA enrollment + do { + let (data, _) = try await URLSession.shared.data(for: request) + + // Step 1: Parse JSON + guard let jsonObject = try? JSONSerialization.jsonObject(with: data) else { + print("❌ Failed to parse JSON from response data") + return false + } + + guard let json = jsonObject as? [String: Any] else { + print("❌ JSON is not a dictionary. Type: \(type(of: jsonObject))") + return false + } + + // Step 2: Extract phoneSessionInfo + guard let info = json["phoneSessionInfo"] as? [String: Any] else { + print("❌ Failed to extract 'phoneSessionInfo' from JSON") + print("Available keys: \(json.keys.joined(separator: ", "))") + if let phoneSessionInfo = json["phoneSessionInfo"] { + print("phoneSessionInfo exists but wrong type: \(type(of: phoneSessionInfo))") + } + return false + } + + // Step 3: Extract sessionInfo + guard let sessionInfo = info["sessionInfo"] as? String else { + print("❌ Failed to extract 'sessionInfo' from phoneSessionInfo") + print("Available keys in phoneSessionInfo: \(info.keys.joined(separator: ", "))") + if let sessionInfoValue = info["sessionInfo"] { + print("sessionInfo exists but wrong type: \(type(of: sessionInfoValue))") + } + return false + } + + // Step 2: Get verification code from emulator + try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second + guard let verificationCode = await getSMSVerificationCode(for: phoneNumber) else { + XCTFail("Failed to retrieve SMS verification code") + return false + } + + // Step 3: Finalize MFA enrollment + let finalizeUrl = + "http://127.0.0.1:9099/identitytoolkit.googleapis.com/v2/accounts/mfaEnrollment:finalize?key=fake-api-key" + guard let finalizeURL = URL(string: finalizeUrl) else { + return false + } + + var finalizeRequest = URLRequest(url: finalizeURL) + finalizeRequest.httpMethod = "POST" + finalizeRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let finalizeBody: [String: Any] = [ + "idToken": idToken, + "phoneVerificationInfo": [ + "sessionInfo": sessionInfo, + "code": verificationCode, + ], + "displayName": displayName, + ] + + guard let finalizeHttpBody = try? JSONSerialization.data(withJSONObject: finalizeBody) else { + return false + } + + finalizeRequest.httpBody = finalizeHttpBody + + let (finalizeData, finalizeResponse) = try await URLSession.shared.data(for: finalizeRequest) + + // Check HTTP status + if let httpResponse = finalizeResponse as? HTTPURLResponse { + print("πŸ“‘ Finalize HTTP Status: \(httpResponse.statusCode)") + } + + guard let json = try? JSONSerialization.jsonObject(with: finalizeData) as? [String: Any] + else { + print("❌ Failed to parse finalize response as JSON") + return false + } + + // Check if we have the new idToken and MFA info + guard let newIdToken = json["idToken"] as? String else { + print("❌ Missing 'idToken' in finalize response") + return false + } + + // Check if refreshToken is present + if let refreshToken = json["refreshToken"] as? String { + print("βœ… Got refreshToken: \(refreshToken.prefix(20))...") + } + + // Check for MFA info in response + if let mfaInfo = json["mfaInfo"] { + print("βœ… MFA info in response: \(mfaInfo)") + } + + return true + + } catch { + print("Failed to enable MFA: \(error.localizedDescription)") + return false + } + } + + /// Retrieves SMS verification codes from the Firebase Auth emulator + /// - Parameters: + /// - phoneNumber: The phone number to retrieve the code for + /// - codeType: The type of code - "enrollment" for MFA enrollment, "verification" for phone + /// verification during resolution + @MainActor + private func getSMSVerificationCode(for phoneNumber: String, + codeType: String = "enrollment") async -> String? { + let emulatorUrl = + "http://127.0.0.1:9099/emulator/v1/projects/flutterfire-e2e-tests/verificationCodes" + + guard let url = URL(string: emulatorUrl) else { + return nil + } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let codes = json["verificationCodes"] as? [[String: Any]] else { + print("❌ Failed to parse verification codes") + return nil + } + + // Filter codes by phone number and type, then get the most recent one + let matchingCodes = codes.filter { codeInfo in + guard let phone = codeInfo["phoneNumber"] as? String else { + print("❌ Code missing phoneNumber field") + return false + } + + // The key difference between enrollment and verification codes: + // - Enrollment codes have full phone numbers (e.g., "+15551234567") + // - Verification codes have masked phone numbers (e.g., "+*******4567") + let isMasked = phone.contains("*") + + // Match phone number + let phoneMatches: Bool + if isMasked { + // Extract last 4 digits from both numbers + let last4OfResponse = String(phone.suffix(4)) + let last4OfTarget = String(phoneNumber.suffix(4)) + phoneMatches = last4OfResponse == last4OfTarget + } else { + // Full phone number match + phoneMatches = phone == phoneNumber + } + + guard phoneMatches else { + return false + } + + if codeType == "enrollment" { + // Enrollment codes have unmasked phone numbers + return !isMasked + } else { // "verification" + // Verification codes have masked phone numbers + return isMasked + } + } + + // Get the last matching code (most recent) + if let lastCode = matchingCodes.last, + let code = lastCode["code"] as? String { + return code + } + + print("❌ No matching code found") + return nil + + } catch { + print("Failed to fetch verification codes: \(error.localizedDescription)") + return nil + } + } + + /// Gets an ID token for a user from the Auth emulator by signing in with email/password + /// This works independently of the app's current auth state + /// - Parameters: + /// - email: The user's email address + /// - password: The user's password (defaults to "123456") + /// - Returns: The user's ID token, or nil if the sign-in failed + @MainActor + private func getIDTokenFromEmulator(email: String, password: String = "123456") async -> String? { + let signInUrl = + "http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=fake-api-key" + + guard let url = URL(string: signInUrl) else { + print("Invalid emulator URL") + return nil + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let requestBody: [String: Any] = [ + "email": email, + "password": password, + "returnSecureToken": true, + ] + + guard let httpBody = try? JSONSerialization.data(withJSONObject: requestBody) else { + print("Failed to serialize sign-in request body") + return nil + } + + request.httpBody = httpBody + + do { + let (data, _) = try await URLSession.shared.data(for: request) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let idToken = json["idToken"] as? String else { + print("Failed to parse sign-in response") + return nil + } + + print("Successfully got ID token from emulator: \(idToken.prefix(20))...") + return idToken + + } catch { + print("Failed to get ID token from emulator: \(error.localizedDescription)") + return nil + } + } + + @MainActor + private func signUpUser(email: String, password: String = "12345678") async throws { + // Create user via Auth Emulator REST API + let url = + URL( + string: "http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake-api-key" + )! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body: [String: Any] = [ + "email": email, + "password": password, + "returnSecureToken": true, + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + XCTFail("Invalid response") + return + } + + guard (200 ... 299).contains(httpResponse.statusCode) else { + let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + XCTFail("Failed to create user. Status: \(httpResponse.statusCode), Error: \(errorMessage)") + return + } + } + + @MainActor private func signInUser(app: XCUIApplication, email: String, + password: String = "123456") throws { + // Ensure we're in sign in flow + let switchFlowButton = app.buttons["switch-auth-flow"] + if switchFlowButton.exists && switchFlowButton.label.contains("Sign In") { + switchFlowButton.tap() + } + + // Fill email field + let emailField = app.textFields["email-field"] + XCTAssertTrue(emailField.waitForExistence(timeout: 6)) + emailField.tap() + emailField.clearAndEnterText(email) + + // Fill password field + let passwordField = app.secureTextFields["password-field"] + passwordField.tap() + passwordField.clearAndEnterText(password) + + // Tap sign in button + let signInButton = app.buttons["sign-in-button"] + signInButton.tap() + } + + @MainActor private func enrollSMSMFA(app: XCUIApplication) throws { + // Navigate to MFA management + let mfaManagementButton = app.buttons["mfa-management-button"] + XCTAssertTrue(mfaManagementButton.waitForExistence(timeout: 5)) + mfaManagementButton.tap() + + // Tap add factor button + let addFactorButton = app.buttons["add-factor-button"] + XCTAssertTrue(addFactorButton.waitForExistence(timeout: 5)) + addFactorButton.tap() + + // Select SMS factor + let factorPicker = app.segmentedControls["factor-type-picker"] + XCTAssertTrue(factorPicker.waitForExistence(timeout: 5)) + factorPicker.buttons["SMS"].tap() + + // Start enrollment + let startButton = app.buttons["start-enrollment-button"] + startButton.tap() + + // Enter phone number + let phoneField = app.textFields["phone-number-field"] + XCTAssertTrue(phoneField.waitForExistence(timeout: 5)) + phoneField.tap() + phoneField.typeText("+15551234567") + + // Send SMS + let sendSMSButton = app.buttons["send-sms-button"] + sendSMSButton.tap() + + // Enter verification code + let codeField = app.textFields["sms-verification-code-field"] + XCTAssertTrue(codeField.waitForExistence(timeout: 10)) + codeField.tap() + codeField.typeText("123456") // This will work in emulator + + // Complete enrollment + let completeButton = app.buttons["complete-enrollment-button"] + completeButton.tap() + + // Wait for completion + let successMessage = app.staticTexts + .containing(NSPredicate(format: "label CONTAINS[cd] 'successfully enrolled'")) + XCTAssertTrue(successMessage.firstMatch.waitForExistence(timeout: 10)) + + // Go back to signed in view + let backButton = app.buttons["back-button"] + if backButton.exists { + backButton.tap() + } + } +} + +// MARK: - XCUIElement Extensions + +extension XCUIElement { + func clearAndEnterText(_ text: String) { + guard let stringValue = value as? String else { + XCTFail("Tried to clear and enter text into a non-string value") + return + } + + tap() + + let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: stringValue.count) + typeText(deleteString) + typeText(text) + } +} diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/ProviderUITests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/ProviderUITests.swift new file mode 100644 index 0000000000..cda57f0c29 --- /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" + ) + } +} diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift new file mode 100644 index 0000000000..bc5bc0313f --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/TestUtils.swift @@ -0,0 +1,151 @@ +import Foundation +import XCTest + +// MARK: - Email Generation + +func createEmail() -> String { + let before = UUID().uuidString.prefix(8) + let after = UUID().uuidString.prefix(6) + return "\(before)@\(after).com" +} + +// MARK: - App Configuration + +/// Creates and configures an XCUIApplication with default test launch arguments +@MainActor func createTestApp(mfaEnabled: Bool = false) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments.append("--test-view-enabled") + if mfaEnabled { + app.launchArguments.append("--mfa-enabled") + } + return app +} + +// MARK: - Alert Handling + +@MainActor func dismissAlert(app: XCUIApplication) { + if app.scrollViews.otherElements.buttons["Not Now"].waitForExistence(timeout: 2) { + app.scrollViews.otherElements.buttons["Not Now"].tap() + } +} + +// MARK: - User Creation + +/// Helper to create a test user in the emulator via REST API (avoids keychain issues) +@MainActor func createTestUser(email: String, password: String = "123456", + verifyEmail: Bool = false) async throws { + // Use Firebase Auth emulator REST API directly to avoid keychain access issues in UI tests + let signUpUrl = + "http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake-api-key" + + guard let url = URL(string: signUpUrl) else { + throw NSError(domain: "TestError", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Invalid emulator URL"]) + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body: [String: Any] = [ + "email": email, + "password": password, + "returnSecureToken": true, + ] + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error" + throw NSError(domain: "TestError", code: 2, + userInfo: [NSLocalizedDescriptionKey: "Failed to create user: \(errorBody)"]) + } + + // If email verification is requested, verify the email + if verifyEmail { + // Parse the response to get the idToken + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let idToken = json["idToken"] as? String { + try await verifyEmailInEmulator(email: email, idToken: idToken) + } + } +} + +// MARK: - Email Verification + +/// Verifies an email address in the emulator using the OOB code mechanism +@MainActor func verifyEmailInEmulator(email: String, + idToken: String, + projectID: String = "flutterfire-e2e-tests", + emulatorHost: String = "127.0.0.1:9099") async throws { + let base = "http://\(emulatorHost)" + + // Step 1: Trigger email verification (creates OOB code in emulator) + var sendReq = URLRequest( + url: URL( + string: "\(base)/identitytoolkit.googleapis.com/v1/accounts:sendOobCode?key=fake-api-key" + )! + ) + sendReq.httpMethod = "POST" + sendReq.setValue("application/json", forHTTPHeaderField: "Content-Type") + sendReq.httpBody = try JSONSerialization.data(withJSONObject: [ + "requestType": "VERIFY_EMAIL", + "idToken": idToken, + ]) + + let (_, sendResp) = try await URLSession.shared.data(for: sendReq) + guard let http = sendResp as? HTTPURLResponse, http.statusCode == 200 else { + throw NSError(domain: "EmulatorError", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to send verification email"]) + } + + // Step 2: Fetch OOB codes from emulator + let oobURL = URL(string: "\(base)/emulator/v1/projects/\(projectID)/oobCodes")! + let (oobData, oobResp) = try await URLSession.shared.data(from: oobURL) + guard (oobResp as? HTTPURLResponse)?.statusCode == 200 else { + throw NSError(domain: "EmulatorError", code: 2, + userInfo: [NSLocalizedDescriptionKey: "Failed to fetch OOB codes"]) + } + + struct OobEnvelope: Decodable { let oobCodes: [OobItem] } + struct OobItem: Decodable { + let oobCode: String + let email: String + let requestType: String + let creationTime: String? + } + + let envelope = try JSONDecoder().decode(OobEnvelope.self, from: oobData) + + // Step 3: Find most recent VERIFY_EMAIL code for this email + let iso = ISO8601DateFormatter() + let codeItem = envelope.oobCodes + .filter { + $0.email.caseInsensitiveCompare(email) == .orderedSame && $0.requestType == "VERIFY_EMAIL" + } + .sorted { + let d0 = $0.creationTime.flatMap { iso.date(from: $0) } ?? .distantPast + let d1 = $1.creationTime.flatMap { iso.date(from: $0) } ?? .distantPast + return d0 > d1 + } + .first + + guard let oobCode = codeItem?.oobCode else { + throw NSError(domain: "EmulatorError", code: 3, + userInfo: [ + NSLocalizedDescriptionKey: "No VERIFY_EMAIL OOB code found for \(email)", + ]) + } + + // Step 4: Apply the OOB code (simulate clicking verification link) + let verifyURL = + URL(string: "\(base)/emulator/action?mode=verifyEmail&oobCode=\(oobCode)&apiKey=fake-api-key")! + let (_, verifyResp) = try await URLSession.shared.data(from: verifyURL) + guard (verifyResp as? HTTPURLResponse)?.statusCode == 200 else { + throw NSError(domain: "EmulatorError", code: 4, + userInfo: [NSLocalizedDescriptionKey: "Failed to apply OOB code"]) + } +}