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..95a51f6e27 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift @@ -0,0 +1,41 @@ +// 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 + let provider: AuthProviderSwift + public init(provider: AuthProviderSwift) { + self.provider = provider + } +} + +extension SignInWithAppleButton: View { + public var body: some View { + AuthProviderButton( + label: authService.string.appleLoginButtonLabel, + style: .apple, + accessibilityId: "sign-in-with-apple-button" + ) { + Task { + 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..74575684cd 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift @@ -15,16 +15,56 @@ import FirebaseAuth import SwiftUI -public struct AccountMergeConflictContext: LocalizedError { +/// Describes the specific type of account conflict that occurred +public enum AccountConflictType: Equatable { + /// Account exists with a different provider (e.g., user signed up with Google, trying to use + /// email) + /// Solution: Sign in with existing provider, then link the new credential + case accountExistsWithDifferentCredential + + /// The credential is already linked to another account + /// Solution: User must sign in with that account or unlink the credential + case credentialAlreadyInUse + + /// Email is already registered with another method + /// Solution: Sign in with existing method, then link if desired + case emailAlreadyInUse + + /// Trying to link anonymous account to an existing account + /// Solution: Sign out of anonymous, then sign in with the credential + case anonymousUpgradeConflict +} + +public struct AccountConflictContext: LocalizedError, Identifiable, Equatable { + public let id = UUID() + public let conflictType: AccountConflictType public let credential: AuthCredential public let underlyingError: Error public let message: String - // TODO: - should make this User type once fixed upstream in firebase-ios-sdk. See: https://github.com/firebase/FirebaseUI-iOS/pull/1247#discussion_r2085455355 - public let uid: String? + public let email: String? + + /// Human-readable description of the conflict type + public var conflictDescription: String { + switch conflictType { + case .accountExistsWithDifferentCredential: + return "This account is already registered with a different sign-in method." + case .credentialAlreadyInUse: + return "This credential is already linked to another account." + case .emailAlreadyInUse: + return "This email address is already in use." + case .anonymousUpgradeConflict: + return "Cannot link anonymous account to an existing account." + } + } public var errorDescription: String? { return message } + + public static func == (lhs: AccountConflictContext, rhs: AccountConflictContext) -> Bool { + // Compare by id since each AccountConflictContext instance is unique + lhs.id == rhs.id + } } public enum AuthServiceError: LocalizedError { @@ -35,7 +75,12 @@ public enum AuthServiceError: LocalizedError { case reauthenticationRequired(String) case invalidCredentials(String) case signInFailed(underlying: Error) - case accountMergeConflict(context: AccountMergeConflictContext) + case accountConflict(AccountConflictContext) + case providerNotFound(String) + case multiFactorAuth(String) + case rootViewControllerNotFound(String) + case providerAuthenticationFailed(String) + case signInCancelled(String) public var errorDescription: String? { switch self { @@ -51,10 +96,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)" - case let .accountMergeConflict(context): + // 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 .accountConflict(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..7865931f7c 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift @@ -14,8 +14,11 @@ import FirebaseAuth import Foundation +import SwiftUI public struct AuthConfiguration { + public let logo: ImageResource? + public let languageCode: String? public let shouldHideCancelButton: Bool public let interactiveDismissEnabled: Bool public let shouldAutoUpgradeAnonymousUsers: Bool @@ -25,21 +28,37 @@ public struct AuthConfiguration { public let emailLinkSignInActionCodeSettings: ActionCodeSettings? public let verifyEmailActionCodeSettings: ActionCodeSettings? - public init(shouldHideCancelButton: Bool = false, + // MARK: - MFA Configuration + + public let mfaEnabled: Bool + public let allowedSecondFactors: Set + public let mfaIssuer: String + + public init(logo: ImageResource? = nil, + languageCode: String? = nil, + shouldHideCancelButton: Bool = false, interactiveDismissEnabled: Bool = true, shouldAutoUpgradeAnonymousUsers: Bool = false, customStringsBundle: Bundle? = nil, 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.logo = logo self.shouldHideCancelButton = shouldHideCancelButton self.interactiveDismissEnabled = interactiveDismissEnabled self.shouldAutoUpgradeAnonymousUsers = shouldAutoUpgradeAnonymousUsers self.customStringsBundle = customStringsBundle + self.languageCode = languageCode self.tosUrl = tosUrl 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..faf83645ec 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 { +public protocol PhoneAuthProviderSwift: AuthProviderSwift { @MainActor func verifyPhoneNumber(phoneNumber: String) async throws -> String + @MainActor func createAuthCredential(verificationId: String, + verificationCode: String) async throws -> AuthCredential } public enum AuthenticationState { @@ -41,15 +40,24 @@ 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 + case enterPhoneNumber + case enterVerificationCode(verificationID: String, fullPhoneNumber: String) +} + +public enum SignInOutcome: @unchecked Sendable { + case mfaRequired(MFARequired) + case signedIn(AuthDataResult?) } @MainActor @@ -78,94 +86,102 @@ 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 { public init(configuration: AuthConfiguration = AuthConfiguration(), auth: Auth = Auth.auth()) { self.auth = auth self.configuration = configuration - string = StringUtils(bundle: configuration.customStringsBundle ?? Bundle.module) + string = StringUtils( + bundle: configuration.customStringsBundle ?? Bundle.module, + languageCode: configuration.languageCode + ) 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 let string: StringUtils + public var currentUser: User? + public var authenticationState: AuthenticationState = .unauthenticated + public var authenticationFlow: AuthenticationFlow = .signIn + private var _currentError: AlertError? + + /// A binding that allows SwiftUI views to observe and clear errors + public var currentError: Binding { + Binding( + get: { self._currentError }, + set: { newValue in + if newValue == nil { + self._currentError = nil + } + } + ) } - public func dismissModal() { - isShowingAuthModal = false - } + public let passwordPrompt: PasswordPromptCoordinator = .init() + public var currentMFARequired: MFARequired? + private var currentMFAResolver: MultiFactorResolver? - // MARK: - End AuthPickerView Modal APIs + /// Current account conflict context - observe this to handle conflicts and update backend + public private(set) var currentAccountConflict: AccountConflictContext? // 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 var currentPhoneProvider: PhoneAuthProviderSwift? { + providers.compactMap { $0.provider as? PhoneAuthProviderSwift }.first + } + + 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 +189,15 @@ 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") - } - return provider - } - } - - private var phoneAuthProvider: any PhoneAuthProviderAuthUIProtocol { - get throws { - guard let provider = unsafePhoneAuthProvider else { - fatalError("`PhoneAuthProviderAuthUI` has not been configured") - } - return provider + public func signIn(_ provider: AuthProviderSwift) async throws -> SignInOutcome { + do { + 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 +224,12 @@ public final class AuthService { } func reset() { - errorMessage = "" + _currentError = nil + currentAccountConflict = 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 +239,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,57 +251,67 @@ 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 { + // Possible conflicts from user.link(): + // - credentialAlreadyInUse: credential is already linked to another account + // - emailAlreadyInUse: email from credential is already used by another account + // - accountExistsWithDifferentCredential: account exists with different sign-in method authenticationState = .unauthenticated - errorMessage = string.localizedErrorMessage( - for: error - ) - throw error + try handleErrorWithConflictCheck(error: error, credential: credentials) } } - 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) - } catch let error as NSError { - 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.", - uid: currentUser?.uid - ) - throw AuthServiceError.accountMergeConflict(context: context) - } + let result = try await currentUser?.link(with: credentials) + updateAuthenticationState() + return .signedIn(result) + } catch { throw error } } - 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 - ) - throw 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) + } + } + + // Possible conflicts from auth.signIn(with:): + // - accountExistsWithDifferentCredential: account exists with different provider + // - credentialAlreadyInUse: credential is already linked to another account + try handleErrorWithConflictCheck(error: error, credential: credentials) } } - 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,36 @@ 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 + let credential = EmailAuthProvider.credential(withEmail: email, password: password) 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 { + // Possible conflicts from auth.createUser(): + // - emailAlreadyInUse: email is already registered with another account authenticationState = .unauthenticated - errorMessage = string.localizedErrorMessage( - for: error - ) - throw error + try handleErrorWithConflictCheck(error: error, credential: credential) } } - 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 +412,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 +420,7 @@ public extension AuthService { actionCodeSettings: actionCodeSettings ) } catch { - errorMessage = string.localizedErrorMessage( - for: error - ) + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) throw error } } @@ -431,7 +431,19 @@ public extension AuthService { throw AuthServiceError .invalidEmailLink("email address is missing from app storage. Is this the same device?") } - let link = url.absoluteString + let urlString = url.absoluteString + + guard let originalLink = CommonUtils.getQueryParamValue(from: urlString, paramName: "link") + else { + throw AuthServiceError + .invalidEmailLink("'link' parameter is missing from the email link URL") + } + + guard let link = originalLink.removingPercentEncoding else { + throw AuthServiceError + .invalidEmailLink("Failed to decode Link URL") + } + guard let continueUrl = CommonUtils.getQueryParamValue(from: link, paramName: "continueUrl") else { throw AuthServiceError @@ -453,10 +465,18 @@ public extension AuthService { emailLink = nil } } catch { - errorMessage = string.localizedErrorMessage( - for: error - ) - throw error + // Reconstruct credential for conflict handling + let link = url.absoluteString + guard let email = emailLink else { + throw AuthServiceError + .invalidEmailLink("email address is missing from app storage. Is this the same device?") + } + let credential = EmailAuthProvider.credential(withEmail: email, link: link) + + // Possible conflicts from auth.signIn(withEmail:link:): + // - accountExistsWithDifferentCredential: account exists with different provider + // - credentialAlreadyInUse: credential is already linked to another account + try handleErrorWithConflictCheck(error: error, credential: credential) } } @@ -488,49 +508,559 @@ 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: - Account Conflict Helper Methods + + private func determineConflictType(from error: NSError) -> AccountConflictType? { + switch error.code { + case AuthErrorCode.accountExistsWithDifferentCredential.rawValue: + return shouldHandleAnonymousUpgrade ? .anonymousUpgradeConflict : + .accountExistsWithDifferentCredential + case AuthErrorCode.credentialAlreadyInUse.rawValue: + return shouldHandleAnonymousUpgrade ? .anonymousUpgradeConflict : .credentialAlreadyInUse + case AuthErrorCode.emailAlreadyInUse.rawValue: + return shouldHandleAnonymousUpgrade ? .anonymousUpgradeConflict : .emailAlreadyInUse + default: + return nil + } + } + + private func createConflictContext(from error: NSError, + conflictType: AccountConflictType, + credential: AuthCredential) -> AccountConflictContext { + let updatedCredential = error + .userInfo[AuthErrorUserInfoUpdatedCredentialKey] as? AuthCredential ?? credential + let email = error.userInfo[AuthErrorUserInfoEmailKey] as? String + + return AccountConflictContext( + conflictType: conflictType, + credential: updatedCredential, + underlyingError: error, + message: string.localizedErrorMessage(for: error), + email: email + ) + } + + /// Handles account conflict errors by creating context, storing it, and throwing structured error + /// - Parameters: + /// - error: The error to check and handle + /// - credential: The credential that caused the conflict + /// - Throws: AuthServiceError.accountConflict if it's a conflict error, otherwise rethrows the + /// original error + private func handleErrorWithConflictCheck(error: Error, + credential: AuthCredential) throws -> Never { + // Check for account conflict errors + if let error = error as NSError?, + let conflictType = determineConflictType(from: error) { + let context = createConflictContext( + from: error, + conflictType: conflictType, + credential: credential + ) + + // Store it for consumers to observe + currentAccountConflict = context + + // Only set error alert if we're NOT auto-handling it + if conflictType != .anonymousUpgradeConflict { + updateError(message: context.message, underlyingError: error) + } + + // Throw the specific error with context + throw AuthServiceError.accountConflict(context) + } else { + updateError(message: string.localizedErrorMessage(for: error), underlyingError: error) + throw error + } + } + + // 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..9f57074e39 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Strings/Localizable.xcstrings +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Strings/Localizable.xcstrings @@ -1,1152 +1,4961 @@ { - "sourceLanguage" : "en", - "strings" : { - "%@" : { - + "sourceLanguage": "en", + "strings": { + "%@": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@" + } + } + } + }, + "••••••%@": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "••••••%@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "••••••%@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "••••••%@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "••••••%@" + } + } + } + }, + "Account: %@": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Konto: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Account: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cuenta: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Compte : %@" + } + } + } + }, + "AccountDisabledError": { + "comment": "Error message displayed when the account is disabled. Use short/abbreviated translation for 'email' which is less than 15 chars.", + "extractionState": "migrated", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese E-Mail-Adresse gehört zu einem deaktivierten Konto." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "That email address is for an account that has been disabled." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esa dirección de correo es de una cuenta que ha sido deshabilitada." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette adresse e-mail correspond à un compte qui a été désactivé." + } + } + } + }, + "ActionCantBeUndone": { + "comment": "Alert message shown before account deletion.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Aktion kann nicht rückgängig gemacht werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This action can't be undone" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esta acción no se puede deshacer" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette action ne peut pas être annulée" + } + } + } + }, + "Add an extra layer of security to your account": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Füge deinem Konto eine zusätzliche Sicherheitsebene hinzu" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add an extra layer of security to your account" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agrega una capa adicional de seguridad a tu cuenta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajoute une couche de sécurité supplémentaire à ton compte" + } + } + } + }, + "Add Another Method": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Weitere Methode hinzufügen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add Another Method" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agregar otro método" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter une autre méthode" + } + } + } + }, + "AddPasswordAlertMessage": { + "comment": "Alert message shown when adding account password.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Um ein Passwort zu deinem Konto hinzuzufügen, musst du dich erneut anmelden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "To add password to your account, you will need to sign in again." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Para agregar una contraseña a tu cuenta, deberás iniciar sesión nuevamente." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pour ajouter un mot de passe à ton compte, tu devras te reconnecter." + } + } + } + }, + "AddPasswordTitle": { + "comment": "Controller title shown when adding password to account.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort hinzufügen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add password" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agregar contraseña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter un mot de passe" + } + } + } + }, + "Already have an account?": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hast du bereits ein Konto?" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Already have an account?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Ya tienes una cuenta?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tu as déjà un compte ?" + } + } + } + }, + "AS_AddPassword": { + "comment": "Account Settings cell title Add Password.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort hinzufügen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add password" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agregar contraseña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter un mot de passe" + } + } + } + }, + "AS_ChangePassword": { + "comment": "Account Settings cell title Change Password.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort ändern" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Change password" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cambiar contraseña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Changer le mot de passe" + } + } + } + }, + "AS_DeleteAccount": { + "comment": "Account Settings cell title Delete Account.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Konto löschen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete Account" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar cuenta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer le compte" + } + } + } + }, + "AS_Email": { + "comment": "Account Settings cell title Email. Use short/abbreviated translation for 'email' which is less than 15 chars.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "E-Mail" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Email" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Correo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "E-mail" + } + } + } + }, + "AS_Name": { + "comment": "Account Settings cell title Name.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Name" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Name" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nom" + } + } + } + }, + "AS_SectionLinkedAccounts": { + "comment": "Account Settings section title Linked Accounts.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verknüpfte Konten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Linked Accounts" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cuentas vinculadas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Comptes liés" + } + } + } + }, + "AS_SectionProfile": { + "comment": "Account Settings section title Profile.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Profil" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Profile" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Perfil" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Profil" + } + } + } + }, + "AS_SectionSecurity": { + "comment": "Account Settings section title Security.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sicherheit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Security" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Seguridad" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sécurité" + } + } + } + }, + "AS_SignOut": { + "comment": "Account Settings cell title Sign Out.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Abmelden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign Out" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar sesión" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Se déconnecter" + } + } + } + }, + "Authenticating...": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Authentifizierung läuft..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Authenticating..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Autenticando..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Authentification..." + } + } + } + }, + "Authentication Method": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Authentifizierungsmethode" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Authentication Method" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Método de autenticación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Méthode d'authentification" + } + } + } + }, + "Authenticator App": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Authentifizierungs-App" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Authenticator App" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aplicación de autenticación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Application d'authentification" + } + } + } + }, + "AuthPickerTitle": { + "comment": "Title for auth picker screen.", + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mit Firebase anmelden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in with Firebase" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Iniciar sesión con Firebase" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Se connecter avec Firebase" + } + } + } + }, + "Back": { + "comment": "Back button title.", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Back" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Atrás" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Retour" + } + } + } + }, + "Cancel": { + "comment": "Cancel button title.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Abbrechen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Annuler" + } + } + } + }, + "CannotAuthenticateError": { + "comment": "Error message displayed when the app cannot authenticate user's account.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieser Kontotyp wird von dieser App nicht unterstützt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This type of account isn't supported by this app" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Este tipo de cuenta no es compatible con esta aplicación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ce type de compte n'est pas pris en charge par cette application" + } + } + } + }, + "CantFindProvider": { + "comment": "Error message displayed when FUIAuth is not configured with third party provider. Parameter is value of provider (e g Google, Facebook etc)", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anbieter für %@ kann nicht gefunden werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Can't find provider for %@." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede encontrar el proveedor para %@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de trouver le fournisseur pour %@." + } + } + } + }, + "Change number": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nummer ändern" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Change number" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cambiar número" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Changer le numéro" + } + } + } + }, + "Choose Authentication Method": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Authentifizierungsmethode wählen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose Authentication Method" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elige el método de autenticación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisir la méthode d'authentification" + } + } + } + }, + "Choose verification method:": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verifizierungsmethode wählen:" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose verification method:" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elige el método de verificación:" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisis la méthode de vérification :" + } + } + } + }, + "ChoosePassword": { + "comment": "Placeholder for the password text field in a sign up form.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort wählen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose password" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elegir contraseña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisis un mot de passe" + } + } + } + }, + "Close": { + "comment": "Alert button title Close.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Schließen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Close" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer" + } + } + } + }, + "Complete Setup": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Einrichtung abschließen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Complete Setup" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Completar configuración" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Terminer la configuration" + } + } + } + }, + "Complete Sign-In": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anmeldung abschließen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Complete Sign-In" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Completar inicio de sesión" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Terminer la connexion" + } + } + } + }, + "Complete sign-in with your second factor": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Schließe die Anmeldung mit deinem zweiten Faktor ab" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Complete sign-in with your second factor" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Completa el inicio de sesión con tu segundo factor" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Termine la connexion avec ton deuxième facteur" + } + } + } + }, + "ConfirmEmail": { + "comment": "Title of confirm email label.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "E-Mail bestätigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm Email" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Confirmar correo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Confirmer l'e-mail" + } + } + } + }, + "Confirm Password": { + "comment": "Field label for confirming password", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort bestätigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm Password" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Confirmar contraseña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Confirmer le mot de passe" + } + } + } + }, + "ConfirmPasswordInputLabel": { + "comment": "Input label for confirming password when signing up", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort bestätigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm Password" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Confirmar contraseña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Confirmer le mot de passe" + } + } + } + }, + "Copied to clipboard!": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "In die Zwischenablage kopiert!" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Copied to clipboard!" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¡Copiado al portapapeles!" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copié dans le presse-papiers !" + } + } + } + }, + "Delete": { + "comment": "Text of Delete action button.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Löschen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer" + } + } + } + }, + "Delete Account": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Konto löschen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete Account" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar cuenta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer le compte" + } + } + } + }, + "Delete Account?": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Konto löschen?" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete Account?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Eliminar cuenta?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer le compte ?" + } + } + } + }, + "DeleteAccountBody": { + "comment": "Alert message body shown to confirm account deletion action.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dies löscht alle mit deinem Konto verbundenen Daten und kann nicht rückgängig gemacht werden. Du musst dich erneut anmelden, um diese Aktion abzuschließen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This will erase all data associated with your account, and can't be undone You will need to sign in again to complete this action" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esto borrará todos los datos asociados con tu cuenta y no se puede deshacer. Deberás iniciar sesión nuevamente para completar esta acción" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cela effacera toutes les données associées à ton compte et ne peut pas être annulé. Tu devras te reconnecter pour effectuer cette action" + } + } + } + }, + "DeleteAccountConfirmationMessage": { + "comment": "Explanation message shown before deleting account.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dies löscht alle mit deinem Konto verbundenen Daten und kann nicht rückgängig gemacht werden. Bist du sicher, dass du dein Konto löschen möchtest?" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This will erase all data associated with your account, and can't be undone. Are you sure you want to delete your account?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esto borrará todos los datos asociados con tu cuenta y no se puede deshacer. ¿Estás seguro de que deseas eliminar tu cuenta?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cela effacera toutes les données associées à ton compte et ne peut pas être annulé. Es-tu sûr de vouloir supprimer ton compte ?" + } + } + } + }, + "DeleteAccountConfirmationTitle": { + "comment": "Alert message title shown to confirm account deletion action.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Konto löschen?" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete Account?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Eliminar cuenta?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer le compte ?" + } + } + } + }, + "DeleteAccountControllerTitle": { + "comment": "Title of Controller shown before deleting account", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Konto löschen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete account" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar cuenta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer le compte" + } + } + } + }, + "Display Name": { + "comment": "Field label for display name", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigename" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Display Name" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre para mostrar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nom affiché" + } + } + } + }, + "Don't have an account yet?": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hast du noch kein Konto?" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Don't have an account yet?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Aún no tienes una cuenta?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tu n'as pas encore de compte ?" + } + } + } + }, + "EditEmailTitle": { + "comment": "Controller title shown when editing account email. Use short/abbreviated translation for 'email' which is less than 15 chars.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "E-Mail bearbeiten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit email" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Editar correo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Modifier l'e-mail" + } + } + } + }, + "EditNameTitle": { + "comment": "Controller title shown when editing account name.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Name bearbeiten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit name" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Editar nombre" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Modifier le nom" + } + } + } + }, + "EditPasswordAlertMessage": { + "comment": "Alert message shown when editing account password.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Um das Passwort deines Kontos zu ändern, musst du dich erneut anmelden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "To change password to your account, you will need to sign in again." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Para cambiar la contraseña de tu cuenta, deberás iniciar sesión nuevamente." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pour modifier le mot de passe de ton compte, tu devras te reconnecter." + } + } + } + }, + "EditPasswordTitle": { + "comment": "Controller title shown when editing password to account.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort ändern" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Change password" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cambiar contraseña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Changer le mot de passe" + } + } + } + }, + "Email": { + "comment": "Field label for email", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "E-Mail" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Email" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Correo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "E-mail" + } + } + } + }, + "EmailAlreadyInUseError": { + "comment": "Error message displayed when the email address is already in use. Use short/abbreviated translation for 'email' which is less than 15 chars.", + "extractionState": "migrated", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die E-Mail-Adresse wird bereits von einem anderen Konto verwendet." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The email address is already in use by another account." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La dirección de correo ya está en uso por otra cuenta." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette adresse e-mail est déjà utilisée par un autre compte." + } + } + } + }, + "Enter code from app": { + "comment": "Prompt for entering code from authenticator app", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Code aus der App eingeben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter code from app" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ingresa el código de la aplicación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisis le code de l'application" + } + } + } + }, + "Enter display name for this authenticator": { + "comment": "Prompt for entering display name for authenticator", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigename für diesen Authentifikator eingeben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter display name for this authenticator" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ingresa el nombre para mostrar de este autenticador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisis le nom affiché pour cet authentificateur" + } + } + } + }, + "Enter display name for this device": { + "comment": "Prompt for entering display name for device", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigename für dieses Gerät eingeben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter display name for this device" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ingresa el nombre para mostrar de este dispositivo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisis le nom affiché pour cet appareil" + } + } + } + }, + "Enter phone number": { + "comment": "Prompt for entering phone number", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Telefonnummer eingeben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter phone number" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ingresa el número de teléfono" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisis le numéro de téléphone" + } + } + } + }, + "EmailLinkSignInLabel": { + "comment": "Button label to push user to email link sign-in", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lieber mit E-Mail-Link anmelden?" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Prefer Email link sign-in?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Prefieres iniciar sesión con enlace por correo?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Préfères-tu la connexion par lien e-mail ?" + } + } + } + }, + "EmailLinkSignInTitle": { + "comment": "Sign in with email link View title", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mit E-Mail-Link anmelden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in with email link" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Iniciar sesión con enlace por correo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Se connecter avec un lien e-mail" + } + } + } + }, + "EmailsDontMatch": { + "comment": "Error message displayed when after re-authorization current user's email and re-authorized user's email doesn't match. Use short/abbreviated translation for 'email' which is less than 15 chars.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "E-Mails stimmen nicht überein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Emails don't match" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Los correos no coinciden" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les e-mails ne correspondent pas" + } + } + } + }, + "EmailSentConfirmationMessage": { + "comment": "Message displayed after email is sent. The placeholder is the email address that the email is sent to.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Eine Anmelde-E-Mail mit zusätzlichen Anweisungen wurde an %@ gesendet. Überprüfe deine E-Mails, um die Anmeldung abzuschließen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "A sign-in email with additional instructions was sent to %@. Check your email to complete sign-in." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se envió un correo de inicio de sesión con instrucciones adicionales a %@. Revisa tu correo para completar el inicio de sesión." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Un e-mail de connexion avec des instructions supplémentaires a été envoyé à %@. Vérifie tes e-mails pour terminer la connexion." + } + } + } + }, + "Enrolled Methods": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Registrierte Methoden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enrolled Methods" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Métodos registrados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Méthodes inscrites" + } + } + } + }, + "Enrolled: %@": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Registriert: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enrolled: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Registrado: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Inscrit : %@" + } + } + } + }, + "Enter 6-digit code": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "6-stelligen Code eingeben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter 6-digit code" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ingresa el código de 6 dígitos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisis le code à 6 chiffres" + } + } + } + }, + "Enter the 6-digit code from your authenticator app": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gib den 6-stelligen Code aus deiner Authentifizierungs-App ein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter the 6-digit code from your authenticator app" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ingresa el código de 6 dígitos de tu aplicación de autenticación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisis le code à 6 chiffres de ton application d'authentification" + } + } + } + }, + "Enter Verification Code": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bestätigungscode eingeben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter Verification Code" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ingresa el código de verificación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisis le code de vérification" + } + } + } + }, + "Enter Your Phone Number": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gib deine Telefonnummer ein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter Your Phone Number" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ingresa tu número de teléfono" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisis ton numéro de téléphone" + } + } + } + }, + "EnterYourEmail": { + "comment": "Title for email entry screen, email text field placeholder. Use short/abbreviated translation for 'email' which is less than 15 chars.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gib deine E-Mail ein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter your email" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ingresa tu correo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisis ton e-mail" + } + } + } + }, + "EnterYourPassword": { + "comment": "Password text field placeholder.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gib dein Passwort ein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter your password" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ingresa tu contraseña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisis ton mot de passe" + } + } + } }, - "AccountDisabledError" : { - "comment" : "Error message displayed when the account is disabled. Use short/abbreviated translation for 'email' which is less than 15 chars.", - "extractionState" : "migrated", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "That email address is for an account that has been disabled." + "Error": { + "comment": "Alert title Error.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehler" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Erreur" + } + } + } + }, + "ExistingAccountTitle": { + "comment": "Title of an alert shown to an existing user coming back to the app.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Du hast bereits ein Konto" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "You already have an account" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ya tienes una cuenta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tu as déjà un compte" + } + } + } + }, + "FirstAndLastName": { + "comment": "Name text field placeholder.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Vor- und Nachname" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "First & last name" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre y apellido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Prénom et nom" + } + } + } + }, + "ForgotPassword": { + "comment": "Button text for 'Forgot Password' action.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort-Wiederherstellungs-E-Mail senden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Send password recovery email" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviar correo de recuperación de contraseña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envoyer un e-mail de récupération de mot de passe" } } } }, - "ActionCantBeUndone" : { - "comment" : "Alert message shown before account deletion.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "This action can't be undone" + "ForgotPasswordTitle": { + "comment": "Title of forgot password button.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Probleme beim Anmelden?" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Trouble signing in?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Problemas para iniciar sesión?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Problème de connexion ?" + } + } + } + }, + "Get Started": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Loslegen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Get Started" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comenzar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Commencer" } } } }, - "AddPasswordAlertMessage" : { - "comment" : "Alert message shown when adding account password.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "To add password to your account, you will need to sign in again." + "InvalidEmailError": { + "comment": "Error message displayed when user enters an invalid email address. Use short/abbreviated translation for 'email' which is less than 15 chars.", + "extractionState": "migrated", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese E-Mail-Adresse ist nicht korrekt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "That email address isn't correct." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esa dirección de correo no es correcta." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette adresse e-mail n'est pas correcte." } } } }, - "AddPasswordTitle" : { - "comment" : "Controller title shown when adding password to account.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Add password" + "Invalid OAuth Provider": { + "comment": "Error message displayed when OAuth provider configuration is invalid.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ungültiger OAuth-Anbieter" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid OAuth Provider" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Proveedor OAuth inválido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fournisseur OAuth invalide" } } } }, - "Already have an account?" : { - + "InvalidPasswordError": { + "comment": "Error message displayed when user enters an empty password.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort darf nicht leer sein." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Password cannot be empty." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La contraseña no puede estar vacía." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le mot de passe ne peut pas être vide." + } + } + } }, - "AS_AddPassword" : { - "comment" : "Account Settings cell title Add Password.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Add password" + "Login": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anmelden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Login" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Iniciar sesión" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connexion" } } } }, - "AS_ChangePassword" : { - "comment" : "Account Settings cell title Change Password.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Change password" + "Manage Two-Factor Authentication": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zwei-Faktor-Authentifizierung verwalten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage Two-Factor Authentication" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Administrar autenticación de dos factores" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Gérer l'authentification à deux facteurs" } } } }, - "AS_DeleteAccount" : { - "comment" : "Account Settings cell title Delete Account.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Delete Account" + "Manage your authentication methods": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwalte deine Authentifizierungsmethoden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage your authentication methods" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Administra tus métodos de autenticación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Gère tes méthodes d'authentification" } } } }, - "AS_Email" : { - "comment" : "Account Settings cell title Email. Use short/abbreviated translation for 'email' which is less than 15 chars.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Email" + "Manual Entry Key:": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Manueller Eingabeschlüssel:" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Manual Entry Key:" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Clave de entrada manual:" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Clé de saisie manuelle :" } } } }, - "AS_Name" : { - "comment" : "Account Settings cell title Name.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Name" + "MFA is not enabled in the current configuration. Please contact your administrator.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "MFA ist in der aktuellen Konfiguration nicht aktiviert. Bitte kontaktiere deinen Administrator." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "MFA is not enabled in the current configuration. Please contact your administrator." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "MFA no está habilitado en la configuración actual. Por favor, contacta a tu administrador." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'authentification multifacteur n'est pas activée dans la configuration actuelle. Contacte ton administrateur." } } } }, - "AS_SectionLinkedAccounts" : { - "comment" : "Account Settings section title Linked Accounts.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Linked Accounts" + "Multi-Factor Authentication Disabled": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mehr-Faktor-Authentifizierung deaktiviert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Multi-Factor Authentication Disabled" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Autenticación multifactor deshabilitada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Authentification multifacteur désactivée" } } } }, - "AS_SectionProfile" : { - "comment" : "Account Settings section title Profile.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Profile" + "Name": { + "comment": "Label next to a name text field.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Name" } - } - } - }, - "AS_SectionSecurity" : { - "comment" : "Account Settings section title Security.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Security" + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Name" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nom" } } } }, - "AS_SignOut" : { - "comment" : "Account Settings cell title Sign Out.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sign Out" + "Next": { + "comment": "Next button title.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Weiter" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Next" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Siguiente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Suivant" } } } }, - "AuthPickerTitle" : { - "comment" : "Title for auth picker screen.", - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Welcome" + "No Authentication Methods": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Authentifizierungsmethoden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No Authentication Methods" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin métodos de autenticación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune méthode d'authentification" } } } }, - "Back" : { - "comment" : "Back button title." - }, - "Cancel" : { - "comment" : "Cancel button title.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cancel" + "No Authentication Methods Available": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Authentifizierungsmethoden verfügbar" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No Authentication Methods Available" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No hay métodos de autenticación disponibles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune méthode d'authentification disponible" } } } }, - "CannotAuthenticateError" : { - "comment" : "Error message displayed when the app cannot authenticate user's account.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "This type of account isn't supported by this app" + "No MFA methods are configured as allowed. Please contact your administrator.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine MFA-Methoden sind als erlaubt konfiguriert. Bitte kontaktiere deinen Administrator." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No MFA methods are configured as allowed. Please contact your administrator." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No hay métodos MFA configurados como permitidos. Por favor, contacta a tu administrador." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune méthode d'authentification multifacteur n'est configurée comme autorisée. Contacte ton administrateur." } } } }, - "CantFindProvider" : { - "comment" : "Error message displayed when FUIAuth is not configured with third party provider. Parameter is value of provider (e g Google, Facebook etc)", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Can't find provider for %@." + "OK": { + "comment": "OK button title.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aceptar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "OK" } } } }, - "ChoosePassword" : { - "comment" : "Placeholder for the password text field in a sign up form.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Choose password" + "Password": { + "comment": "Field label for password", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Password" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Contraseña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mot de passe" } } } }, - "Close" : { - "comment" : "Alert button title Close.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Close" + "PasswordRecoveryEmailSentMessage": { + "comment": "Message displayed when the email for password recovery has been sent.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Folge den Anweisungen, die an %@ gesendet wurden, um dein Passwort wiederherzustellen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Follow the instructions sent to %@ to recover your password." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sigue las instrucciones enviadas a %@ para recuperar tu contraseña." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Suis les instructions envoyées à %@ pour récupérer ton mot de passe." } } } }, - "ConfirmEmail" : { - "comment" : "Title of confirm email label.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confirm Email" + "PasswordRecoveryEmailSentTitle": { + "comment": "Title of a message displayed when the email for password recovery has been sent. Use short/abbreviated translation for 'email' which is less than 15 chars.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Überprüfe deine E-Mails" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Check your email" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Revisa tu correo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérifie tes e-mails" } } } }, - "ConfirmPasswordInputLabel" : { - "comment" : "Input label for confirming password when signing up", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confirm Password" + "PasswordRecoveryMessage": { + "comment": "Explanation on how the password of an account can be recovered. Use short/abbreviated translation for 'email' which is less than 15 chars.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Erhalte Anweisungen an diese E-Mail, die erklären, wie du dein Passwort zurücksetzen kannst." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Get instructions sent to this email that explain how to reset your password." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Recibe instrucciones enviadas a este correo que explican cómo restablecer tu contraseña." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Reçois des instructions envoyées à cet e-mail qui expliquent comment réinitialiser ton mot de passe." } } } }, - "Delete" : { - "comment" : "Text of Delete action button.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Delete" + "PasswordRecoveryTitle": { + "comment": "Title for password recovery screen.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort wiederherstellen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Recover password" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Recuperar contraseña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Récupérer le mot de passe" } } } }, - "DeleteAccountBody" : { - "comment" : "Alert message body shown to confirm account deletion action.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "This will erase all data associated with your account, and can't be undone You will need to sign in again to complete this action" + "PasswordVerificationMessage": { + "comment": "Message to explain to the user that password is needed for an account with this email address.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Du hast bereits %@ verwendet, um dich anzumelden. Gib dein Passwort für dieses Konto ein." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "You've already used %@ to sign in. Enter your password for that account." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ya has usado %@ para iniciar sesión. Ingresa tu contraseña para esa cuenta." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tu as déjà utilisé %@ pour te connecter. Saisis ton mot de passe pour ce compte." } } } }, - "DeleteAccountConfirmationMessage" : { - "comment" : "Explanation message shown before deleting account.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "This will erase all data associated with your account, and can't be undone. Are you sure you want to delete your account?" + "Phone": { + "comment": "Field label for phone", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Telefon" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Phone" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Teléfono" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Téléphone" } } } }, - "DeleteAccountConfirmationTitle" : { - "comment" : "Alert message title shown to confirm account deletion action.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Delete Account?" + "Phone Number": { + "comment": "Field label for phone number", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Telefonnummer" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Phone Number" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Número de teléfono" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Numéro de téléphone" } } } }, - "DeleteAccountControllerTitle" : { - "comment" : "Title of Controller shown before deleting account", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Delete account" + "PlaceholderChosePassword": { + "comment": "Placeholder of secret input cell when user changes password.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort wählen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose password" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elegir contraseña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisis un mot de passe" } } } }, - "Don't have an account yet?" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Don't have an account yet?" + "PlaceholderEnterEmail": { + "comment": "Placeholder of input cell when user changes name. Use short/abbreviated translation for 'email' which is less than 15 chars.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gib deine E-Mail ein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter your email" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ingresa tu correo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisis ton e-mail" } } } }, - "EditEmailTitle" : { - "comment" : "Controller title shown when editing account email. Use short/abbreviated translation for 'email' which is less than 15 chars.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Edit email" + "PlaceholderEnterName": { + "comment": "Placeholder of input cell when user changes name.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gib deinen Namen ein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter your name" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ingresa tu nombre" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisis ton nom" } } } }, - "EditNameTitle" : { - "comment" : "Controller title shown when editing account name.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Edit name" + "PlaceholderEnterPassword": { + "comment": "Placeholder of secret input cell when user changes password.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gib dein Passwort ein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter your password" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ingresa tu contraseña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisis ton mot de passe" } } } }, - "EditPasswordAlertMessage" : { - "comment" : "Alert message shown when editing account password.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "To change password to your account, you will need to sign in again." + "PlaceholderNewPassword": { + "comment": "Placeholder of secret input cell when user confirms password.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Neues Passwort" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "New password" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nueva contraseña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouveau mot de passe" } } } }, - "EditPasswordTitle" : { - "comment" : "Controller title shown when editing password to account.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Change password" + "PrivacyPolicy": { + "comment": "Text linked to a web page with the Privacy Policy content.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Datenschutzrichtlinie" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Privacy Policy" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Política de privacidad" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Politique de confidentialité" } } } }, - "EmailAlreadyInUseError" : { - "comment" : "Error message displayed when the email address is already in use. Use short/abbreviated translation for 'email' which is less than 15 chars.", - "extractionState" : "migrated", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The email address is already in use by another account." + "ProviderTitleFacebook": { + "comment": "Title of Facebook provider", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Facebook" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Facebook" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Facebook" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Facebook" } } } }, - "EmailLinkSignInLabel" : { - "comment" : "Button label to push user to email link sign-in", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Prefer Email link sign-in?" + "ProviderTitleGoogle": { + "comment": "Title of Google provider", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Google" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Google" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Google" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Google" } } } }, - "EmailLinkSignInTitle" : { - "comment" : "Sign in with email link View title", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sign in with email link" + "ProviderTitlePassword": { + "comment": "Title of Password/Email provider. Use short/abbreviated translation for 'email' which is less than 15 chars.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "E-Mail" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Email" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Correo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "E-mail" } } } }, - "EmailsDontMatch" : { - "comment" : "Error message displayed when after re-authorization current user's email and re-authorized user's email doesn't match. Use short/abbreviated translation for 'email' which is less than 15 chars.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Emails don't match" + "ProviderTitleTwitter": { + "comment": "Title of Twitter provider", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Twitter" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Twitter" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Twitter" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Twitter" } } } }, - "EmailSentConfirmationMessage" : { - "comment" : "Message displayed after email is sent. The placeholder is the email address that the email is sent to.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "A sign-in email with additional instructions was sent to %@. Check your email to complete sign-in." + "ProviderUsedPreviouslyMessage": { + "comment": "Alert message to let user know what identity provider (second placeholder, ex. Google) was used previously for the email address (first placeholder).", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Du hast bereits %@ verwendet. Melde dich mit %@ an, um fortzufahren." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "You've already used %@. Sign in with %@ to continue." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ya has usado %@. Inicia sesión con %@ para continuar." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tu as déjà utilisé %@. Connecte-toi avec %@ pour continuer." } } } }, - "EnterYourEmail" : { - "comment" : "Title for email entry screen, email text field placeholder. Use short/abbreviated translation for 'email' which is less than 15 chars.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enter your email" + "ReauthenticateEditPasswordAlertMessage": { + "comment": "Alert message shown when re-authenticating before editing account password.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Um dein Passwort zu ändern, musst du zuerst dein aktuelles Passwort eingeben." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "In order to change your password, you first need to enter your current password." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Para cambiar tu contraseña, primero debes ingresar tu contraseña actual." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pour modifier ton mot de passe, tu dois d'abord saisir ton mot de passe actuel." } } } }, - "EnterYourPassword" : { - "comment" : "Password text field placeholder.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enter your password" + "Remove": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Entfernen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Retirer" } } } }, - "Error" : { - "comment" : "Alert title Error.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Error" + "Resend": { + "comment": "Resend button title.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Erneut senden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Resend" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reenviar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Renvoyer" } } } }, - "ExistingAccountTitle" : { - "comment" : "Title of an alert shown to an existing user coming back to the app.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "You already have an account" + "Resend Code": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Code erneut senden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Resend Code" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reenviar código" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Renvoyer le code" } } } }, - "FirstAndLastName" : { - "comment" : "Name text field placeholder.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "First & last name" + "Save": { + "comment": "Save button title.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Speichern" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Guardar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Enregistrer" } } } }, - "ForgotPassword" : { - "comment" : "Button text for 'Forgot Password' action.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Forgot password?" + "Scan QR Code": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "QR-Code scannen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan QR Code" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escanear código QR" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Scanner le code QR" } } } }, - "ForgotPasswordTitle" : { - "comment" : "Title of forgot password button.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trouble signing in?" + "Scan with your authenticator app or tap to open directly": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Scanne mit deiner Authentifizierungs-App oder tippe, um direkt zu öffnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan with your authenticator app or tap to open directly" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escanea con tu aplicación de autenticación o toca para abrir directamente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Scanne avec ton application d'authentification ou appuie pour ouvrir directement" } } } }, - "InvalidEmailError" : { - "comment" : "Error message displayed when user enters an invalid email address. Use short/abbreviated translation for 'email' which is less than 15 chars.", - "extractionState" : "migrated", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "That email address isn't correct." + "Send": { + "comment": "Send button title.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Senden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Send" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envoyer" } } } }, - "InvalidPasswordError" : { - "comment" : "Error message displayed when user enters an empty password.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Password cannot be empty." + "Send Code": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Code senden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Send Code" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviar código" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envoyer le code" } } } }, - "Login" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Login" + "SendEmailSignInLinkButtonLabel": { + "comment": "Button label for sending email sign-in link", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "E-Mail-Anmeldelink senden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Send email sign-in link" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviar enlace de inicio de sesión por correo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envoyer le lien de connexion par e-mail" } } } }, - "Name" : { - "comment" : "Label next to a name text field.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Name" + "Set Up Two-Factor Authentication": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zwei-Faktor-Authentifizierung einrichten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Set Up Two-Factor Authentication" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Configurar autenticación de dos factores" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Configurer l'authentification à deux facteurs" } } } }, - "Next" : { - "comment" : "Next button title.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Next" + "Set up two-factor authentication to add an extra layer of security to your account.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Richte die Zwei-Faktor-Authentifizierung ein, um deinem Konto eine zusätzliche Sicherheitsebene hinzuzufügen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Set up two-factor authentication to add an extra layer of security to your account." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Configura la autenticación de dos factores para agregar una capa adicional de seguridad a tu cuenta." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Configure l'authentification à deux facteurs pour ajouter une couche de sécurité supplémentaire à ton compte." } } } }, - "OK" : { - "comment" : "OK button title.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "OK" + "Sign up": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Registrieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign up" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Registrarse" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "S'inscrire" } } } }, - "PasswordRecoveryEmailSentMessage" : { - "comment" : "Message displayed when the email for password recovery has been sent.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Follow the instructions sent to %@ to recover your password." + "SignedIn": { + "comment": "Title of successfully signed in label.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Angemeldet!" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Signed in!" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¡Sesión iniciada!" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connecté !" } } } }, - "PasswordRecoveryEmailSentTitle" : { - "comment" : "Title of a message displayed when the email for password recovery has been sent. Use short/abbreviated translation for 'email' which is less than 15 chars.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Check your email" + "SignInEmailSent": { + "comment": "Message displayed after the email of sign-in link is sent.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anmelde-E-Mail gesendet" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign-in email Sent" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Correo de inicio de sesión enviado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "E-mail de connexion envoyé" } } } }, - "PasswordRecoveryMessage" : { - "comment" : "Explanation on how the password of an account can be recovered. Use short/abbreviated translation for 'email' which is less than 15 chars.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Get instructions sent to this email that explain how to reset your password." + "SignInTitle": { + "comment": "Title for sign in screen and sign in button.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anmelden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Iniciar sesión" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Se connecter" } } } }, - "PasswordRecoveryTitle" : { - "comment" : "Title for password recovery screen.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Recover password" + "SignInTooManyTimesError": { + "comment": "Error message displayed after user trying to sign in too many times.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Du hast zu oft ein falsches Passwort eingegeben. Versuche es in ein paar Minuten erneut." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "You've entered an incorrect password too many times. Try again in a few minutes." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Has ingresado una contraseña incorrecta demasiadas veces. Intenta nuevamente en unos minutos." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tu as saisi un mot de passe incorrect trop de fois. Réessaie dans quelques minutes." } } } }, - "PasswordVerificationMessage" : { - "comment" : "Message to explain to the user that password is needed for an account with this email address.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "You’ve already used %@ to sign in. Enter your password for that account." + "Sign in with Apple": { + "comment": "Sign in with Apple button label.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mit Apple anmelden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in with Apple" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Iniciar sesión con Apple" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Se connecter avec Apple" } } } }, - "PlaceholderChosePassword" : { - "comment" : "Placeholder of secret input cell when user changes password.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Choose password" + "Sign in with Facebook": { + "comment": "Sign in with Facebook button label.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mit Facebook anmelden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in with Facebook" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Iniciar sesión con Facebook" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Se connecter avec Facebook" } } } }, - "PlaceholderEnterEmail" : { - "comment" : "Placeholder of input cell when user changes name. Use short/abbreviated translation for 'email' which is less than 15 chars.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enter your email" + "Sign in with Google": { + "comment": "Sign in with Google button label.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mit Google anmelden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in with Google" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Iniciar sesión con Google" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Se connecter avec Google" } } } }, - "PlaceholderEnterName" : { - "comment" : "Placeholder of input cell when user changes name.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enter your name" + "Sign in with Phone": { + "comment": "Sign in with Phone button label.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mit Telefon anmelden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in with Phone" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Iniciar sesión con teléfono" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Se connecter avec un téléphone" } } } }, - "PlaceholderEnterPassword" : { - "comment" : "Placeholder of secret input cell when user changes password.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enter your password" + "Sign in with X": { + "comment": "Sign in with X (Twitter) button label.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mit X anmelden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in with X" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Iniciar sesión con X" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Se connecter avec X" } } } }, - "PlaceholderNewPassword" : { - "comment" : "Placeholder of secret input cell when user confirms password.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "New password" + "SignInWithEmail": { + "comment": "Sign in with email button label. Use short/abbreviated translation for 'email' which is less than 15 chars.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mit E-Mail anmelden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in with email" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Iniciar sesión con correo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Se connecter avec un e-mail" } } } }, - "PrivacyPolicy" : { - "comment" : "Text linked to a web page with the Privacy Policy content.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Privacy Policy" + "SignInWithProvider": { + "comment": "Sign in with provider button label.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mit %@ anmelden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in with %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Iniciar sesión con %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Se connecter avec %@" } } } }, - "ProviderTitleFacebook" : { - "comment" : "Title of Facebook provider", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Facebook" + "SignUpTitle": { + "comment": "Title for sign up screen.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Konto erstellen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Create account" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Crear cuenta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Créer un compte" } } } }, - "ProviderTitleGoogle" : { - "comment" : "Title of Google provider", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Google" + "SignUpTooManyTimesError": { + "comment": "Error message displayed when many accounts have been created from same IP address.", + "extractionState": "migrated", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zu viele Kontoanfragen kommen von deiner IP-Adresse. Versuche es in ein paar Minuten erneut." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Too many account requests are coming from your IP address. Try again in a few minutes." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Demasiadas solicitudes de cuenta provienen de tu dirección IP. Intenta nuevamente en unos minutos." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Trop de demandes de compte proviennent de ton adresse IP. Réessaie dans quelques minutes." } } } }, - "ProviderTitlePassword" : { - "comment" : "Title of Password/Email provider. Use short/abbreviated translation for 'email' which is less than 15 chars.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Email" + "SMS Authentication": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "SMS-Authentifizierung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "SMS Authentication" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Autenticación por SMS" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Authentification par SMS" } } } }, - "ProviderTitleTwitter" : { - "comment" : "Title of Twitter provider", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Twitter" + "SMS Verification": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "SMS-Verifizierung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "SMS Verification" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Verificación por SMS" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérification par SMS" } } } }, - "ProviderUsedPreviouslyMessage" : { - "comment" : "Alert message to let user know what identity provider (second placeholder, ex. Google) was used previously for the email address (first placeholder).", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "You’ve already used %@. Sign in with %@ to continue." + "SMS: %@": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "SMS: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "SMS: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "SMS: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "SMS : %@" } } } }, - "ReauthenticateEditPasswordAlertMessage" : { - "comment" : "Alert message shown when re-authenticating before editing account password.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "In order to change your password, you first need to enter your current password." + "Tap to open in authenticator app": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Tippe, um in der Authentifizierungs-App zu öffnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Tap to open in authenticator app" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Toca para abrir en la aplicación de autenticación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Appuie pour ouvrir dans l'application d'authentification" } } } }, - "Resend" : { - "comment" : "Resend button title.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Resend" + "TermsOfService": { + "comment": "Text linked to a web page with the Terms of Service content.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nutzungsbedingungen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Terms of Service" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Términos de servicio" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Conditions d'utilisation" } } } }, - "Save" : { - "comment" : "Save button title.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Save" + "TermsOfServiceMessage": { + "comment": "A message displayed when the first log in screen is displayed. The first placeholder is the terms of service agreement link, the second place holder is the privacy policy agreement link.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Indem du fortfährst, bestätigst du, dass du unsere %@ und %@ akzeptierst." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "By continuing, you are indicating that you accept our %@ and %@." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Al continuar, indicas que aceptas nuestros %@ y %@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En continuant, tu indiques que tu acceptes nos %@ et notre %@." } } } }, - "Send" : { - "comment" : "Send button title.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Send" + "This action cannot be undone. All your data will be permanently deleted. You may need to reauthenticate to complete this action.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Aktion kann nicht rückgängig gemacht werden. Alle deine Daten werden dauerhaft gelöscht. Du musst dich möglicherweise erneut authentifizieren, um diese Aktion abzuschließen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This action cannot be undone. All your data will be permanently deleted. You may need to reauthenticate to complete this action." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esta acción no se puede deshacer. Todos tus datos se eliminarán permanentemente. Es posible que debas volver a autenticarte para completar esta acción." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette action ne peut pas être annulée. Toutes tes données seront définitivement supprimées. Tu devras peut-être te réauthentifier pour effectuer cette action." } } } }, - "SendEmailSignInLinkButtonLabel" : { - "comment" : "Button label for sending email sign-in link", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Send email sign-in link" + "TroubleGettingEmailMessage": { + "comment": "Alert message displayed when user having trouble getting email.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Versuche diese gängigen Lösungen: \n - Überprüfe, ob die E-Mail als Spam markiert oder gefiltert wurde.\n - Überprüfe deine Internetverbindung.\n - Überprüfe, ob du deine E-Mail nicht falsch geschrieben hast.\n - Überprüfe, ob dein Postfach voll ist oder andere Probleme mit den Postfacheinstellungen vorliegen.\n Wenn die obigen Schritte nicht funktioniert haben, kannst du die E-Mail erneut senden. Beachte, dass dadurch der Link in der älteren E-Mail deaktiviert wird." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Try these common fixes: \n - Check if the email was marked as spam or filtered.\n - Check your internet connection.\n - Check that you did not misspell your email.\n - Check that your inbox space is not running out or other inbox settings related issues.\n If the steps above didn't work, you can resend the email. Note that this will deactivate the link in the older email." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Prueba estas soluciones comunes: \n - Verifica si el correo fue marcado como spam o filtrado.\n - Verifica tu conexión a internet.\n - Verifica que no hayas escrito mal tu correo.\n - Verifica que tu bandeja de entrada no esté llena u otros problemas relacionados con la configuración de la bandeja de entrada.\n Si los pasos anteriores no funcionaron, puedes reenviar el correo. Ten en cuenta que esto desactivará el enlace del correo anterior." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Essaie ces solutions courantes :\n - Vérifie si l'e-mail a été marqué comme spam ou filtré.\n - Vérifie ta connexion Internet.\n - Vérifie que tu n'as pas mal orthographié ton e-mail.\n - Vérifie que l'espace de ta boîte de réception n'est pas saturé ou qu'il n'y a pas d'autres problèmes de paramètres de boîte de réception.\n Si les étapes ci-dessus n'ont pas fonctionné, tu peux renvoyer l'e-mail. Note que cela désactivera le lien dans l'ancien e-mail." } } } }, - "Sign up" : { - - }, - "SignedIn" : { - "comment" : "Title of successfully signed in label.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Signed in!" + "TroubleGettingEmailTitle": { + "comment": "Title used in trouble getting email alert view.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Probleme beim Empfang von E-Mails?" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Trouble getting emails?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Problemas para recibir correos?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Problème pour recevoir les e-mails ?" } } } }, - "SignInEmailSent" : { - "comment" : "Message displayed after the email of sign-in link is sent.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sign-in email Sent" + "Two-Factor Authentication": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zwei-Faktor-Authentifizierung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Two-Factor Authentication" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Autenticación de dos factores" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Authentification à deux facteurs" } } } }, - "SignInTitle" : { - "comment" : "Title for sign in screen and sign in button.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sign in" + "Unable to generate QR Code": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "QR-Code kann nicht generiert werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to generate QR Code" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede generar el código QR" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de générer le code QR" } } } }, - "SignInTooManyTimesError" : { - "comment" : "Error message displayed after user trying to sign in too many times.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "You’ve entered an incorrect password too many times. Try again in a few minutes." + "UnlinkAction": { + "comment": "Button title for unlinking account action.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verknüpfung aufheben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unlink" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desvincular" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Délier" } } } }, - "SignInWithEmail" : { - "comment" : "Sign in with email button label. Use short/abbreviated translation for 'email' which is less than 15 chars.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sign in with email" + "UnlinkConfirmationActionTitle": { + "comment": "Alert action title shown before unlinking action.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kontoverknüpfung aufheben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unlink account" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desvincular cuenta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Délier le compte" } } } }, - "SignInWithProvider" : { - "comment" : "Sign in with provider button label.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sign in with %@" + "UnlinkConfirmationMessage": { + "comment": "Alert message shown before unlinking action.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Du kannst dich nicht mehr mit deinem Konto anmelden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "You will no longer be able to sign in using your account" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ya no podrás iniciar sesión usando tu cuenta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tu ne pourras plus te connecter en utilisant ton compte" } } } }, - "SignUpTitle" : { - "comment" : "Title for sign up screen.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Create account" + "UnlinkConfirmationTitle": { + "comment": "Alert title shown before unlinking action.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kontoverknüpfung aufheben?" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unlink account?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Desvincular cuenta?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Délier le compte ?" } } } }, - "SignUpTooManyTimesError" : { - "comment" : "Error message displayed when many accounts have been created from same IP address.", - "extractionState" : "migrated", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Too many account requests are coming from your IP address. Try again in a few minutes." + "UnlinkTitle": { + "comment": "Controller title shown for unlinking account action.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verknüpftes Konto" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Linked account" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cuenta vinculada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Compte lié" } } } }, - "TermsOfService" : { - "comment" : "Text linked to a web page with the Terms of Service content.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Terms of Service" + "Update password": { + "comment": "Update password button label", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort aktualisieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Update password" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar contraseña" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mettre à jour le mot de passe" } } } }, - "TermsOfServiceMessage" : { - "comment" : "A message displayed when the first log in screen is displayed. The first placeholder is the terms of service agreement link, the second place holder is the privacy policy agreement link.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "By continuing, you are indicating that you accept our %@ and %@." + "UpdateEmailAlertMessage": { + "comment": "Alert action message shown before updating email action. Use short/abbreviated translation for 'email' which is less than 15 chars.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Um die mit deinem Konto verknüpfte E-Mail-Adresse zu ändern, musst du dich erneut anmelden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "To change email address associated with your account, you will need to sign in again." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Para cambiar la dirección de correo asociada con tu cuenta, deberás iniciar sesión nuevamente." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pour modifier l'adresse e-mail associée à ton compte, tu devras te reconnecter." } } } }, - "TroubleGettingEmailMessage" : { - "comment" : "Alert message displayed when user having trouble getting email.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Try these common fixes: \n - Check if the email was marked as spam or filtered.\n - Check your internet connection.\n - Check that you did not misspell your email.\n - Check that your inbox space is not running out or other inbox settings related issues.\n If the steps above didn't work, you can resend the email. Note that this will deactivate the link in the older email." + "UpdateEmailVerificationAlertMessage": { + "comment": "Alert action message shown before confirmation of updating email action.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Um dein Passwort zu ändern, musst du zuerst dein aktuelles Passwort eingeben." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "In order to change your password, you first need to enter your current password." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Para cambiar tu contraseña, primero debes ingresar tu contraseña actual." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pour modifier ton mot de passe, tu dois d'abord saisir ton mot de passe actuel." } } } }, - "TroubleGettingEmailTitle" : { - "comment" : "Title used in trouble getting email alert view.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trouble getting emails?" + "Use an authenticator app like Google Authenticator or Authy to generate verification codes.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwende eine Authentifizierungs-App wie Google Authenticator oder Authy, um Bestätigungscodes zu generieren." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Use an authenticator app like Google Authenticator or Authy to generate verification codes." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Usa una aplicación de autenticación como Google Authenticator o Authy para generar códigos de verificación." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilise une application d'authentification comme Google Authenticator ou Authy pour générer des codes de vérification." } } } }, - "UnlinkAction" : { - "comment" : "Button title for unlinking account action.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unlink" + "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.", + "extractionState": "migrated", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese E-Mail-Adresse stimmt mit keinem vorhandenen Konto überein." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "That email address doesn't match an existing account." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esa dirección de correo no coincide con una cuenta existente." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette adresse e-mail ne correspond à aucun compte existant." } } } }, - "UnlinkConfirmationActionTitle" : { - "comment" : "Alert action title shown before unlinking action.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unlink account" + "Send a password recovery link to your email": { + "comment": "Field label for password recovery email", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort-Wiederherstellungslink an deine E-Mail senden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Send a password recovery link to your email" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviar un enlace de recuperación de contraseña a tu correo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envoyer un lien de récupération de mot de passe à ton e-mail" } } } }, - "UnlinkConfirmationMessage" : { - "comment" : "Alert message shown before unlinking action.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "You will no longer be able to sign in using your account" + "Send a sign-in link to your email": { + "comment": "Field label for sign-in email link", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anmeldelink an deine E-Mail senden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Send a sign-in link to your email" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviar un enlace de inicio de sesión a tu correo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envoyer un lien de connexion à ton e-mail" } } } }, - "UnlinkConfirmationTitle" : { - "comment" : "Alert title shown before unlinking action.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unlink account?" + "Verification Code": { + "comment": "Field label for verification code", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bestätigungscode" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Verification Code" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Código de verificación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Code de vérification" } } } }, - "UnlinkTitle" : { - "comment" : "Controller title shown for unlinking account action.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Linked account" + "Verify email address?": { + "comment": "Label for sending email verification to user.", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "E-Mail-Adresse verifizieren?" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Verify email address?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Verificar dirección de correo?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérifier l'adresse e-mail ?" } } } }, - "Update password" : { - "comment" : "Update password button label", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Update password" + "VerifyItsYou": { + "comment": "Alert message title show for re-authorization.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bestätige, dass du es bist" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Verify it's you" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Verifica que eres tú" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérifie que c'est toi" } } } }, - "UpdateEmailAlertMessage" : { - "comment" : "Alert action message shown before updating email action. Use short/abbreviated translation for 'email' which is less than 15 chars.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "To change email address associated with your account, you will need to sign in again." + "We sent a code to %@": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wir haben einen Code an %@ gesendet" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "We sent a code to %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviamos un código a %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nous avons envoyé un code à %@" } } } }, - "UpdateEmailVerificationAlertMessage" : { - "comment" : "Alert action message shown before confirmation of updating email action.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "In order to change your password, you first need to enter your current password." + "We'll send a code to ••••••%@": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wir senden einen Code an ••••••%@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "We'll send a code to ••••••%@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviaremos un código a ••••••%@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nous enverrons un code à ••••••%@" } } } }, - "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.", - "extractionState" : "migrated", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "That email address doesn’t match an existing account." + "We'll send a verification code to this number": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wir senden einen Bestätigungscode an diese Nummer" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "We'll send a verification code to this number" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviaremos un código de verificación a este número" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nous enverrons un code de vérification à ce numéro" } } } }, - "Verify email address?" : { - "comment" : "Label for sending email verification to user.", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verify email address?" + "We'll send a verification code to your phone": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wir senden einen Bestätigungscode an dein Telefon" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "We'll send a verification code to your phone" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviaremos un código de verificación a tu teléfono" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nous enverrons un code de vérification à ton téléphone" } } } }, - "VerifyItsYou" : { - "comment" : "Alert message title show for re-authorization.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verify it's you" + "We'll send a verification code to your phone number each time you sign in.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wir senden jedes Mal einen Bestätigungscode an deine Telefonnummer, wenn du dich anmeldest." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "We'll send a verification code to your phone number each time you sign in." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviaremos un código de verificación a tu número de teléfono cada vez que inicies sesión." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nous enverrons un code de vérification à ton numéro de téléphone chaque fois que tu te connectes." } } } }, - "WeakPasswordError" : { - "comment" : "Error message displayed when the password is too weak.", - "extractionState" : "migrated", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Password must be at least 6 characters long." + "WeakPasswordError": { + "comment": "Error message displayed when the password is too weak.", + "extractionState": "migrated", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort muss mindestens 6 Zeichen lang sein." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Password must be at least 6 characters long." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La contraseña debe tener al menos 6 caracteres." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le mot de passe doit contenir au moins 6 caractères." } } } }, - "WrongPasswordError" : { - "comment" : "Error message displayed when the email and password don't match. Use short/abbreviated translation for 'email' which is less than 15 chars.", - "extractionState" : "migrated", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The email and password you entered don't match." + "WrongPasswordError": { + "comment": "Error message displayed when the email and password don't match. Use short/abbreviated translation for 'email' which is less than 15 chars.", + "extractionState": "migrated", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die E-Mail und das Passwort, die du eingegeben hast, stimmen nicht überein." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The email and password you entered don't match." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El correo y la contraseña que ingresaste no coinciden." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'e-mail et le mot de passe que tu as saisis ne correspondent pas." } } } } }, - "version" : "1.0" + "version": "1.0" } \ No newline at end of file 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..f119cf91b8 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/StringUtils.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/StringUtils.swift @@ -19,14 +19,23 @@ let kKeyNotFound = "Key not found" public class StringUtils { let bundle: Bundle - init(bundle: Bundle) { + let languageCode: String? + + init(bundle: Bundle, languageCode: String? = nil) { self.bundle = bundle + self.languageCode = languageCode } public func localizedString(for key: String) -> String { + // If a specific language code is set, load strings from that language bundle + if let languageCode, let path = bundle.path(forResource: languageCode, ofType: "lproj"), + let localizedBundle = Bundle(path: path) { + return localizedBundle.localizedString(forKey: key, value: nil, table: "Localizable") + } + + // Use default localization let keyLocale = String.LocalizationValue(key) - let value = String(localized: keyLocale, bundle: bundle) - return value + return String(localized: keyLocale, bundle: bundle) } public func localizedErrorMessage(for error: Error) -> String { @@ -217,14 +226,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") } @@ -281,6 +290,34 @@ public class StringUtils { return localizedString(for: "Already have an account?") } + /// Google provider + /// found in: + /// - SignInWithGoogleButton + public var googleLoginButtonLabel: String { + return localizedString(for: "Sign in with Google") + } + + /// Apple provider + /// found in: + /// - SignInWithAppleButton + public var appleLoginButtonLabel: String { + return localizedString(for: "Sign in with Apple") + } + + /// Twitter/X provider + /// found in: + /// - SignInWithTwitterButton + public var twitterLoginButtonLabel: String { + return localizedString(for: "Sign in with X") + } + + /// Phone provider + /// found in: + /// - PhoneAuthButtonView + public var phoneLoginButtonLabel: String { + return localizedString(for: "Sign in with Phone") + } + /// Facebook provider /// found in: /// - SignInWithFacebookButton @@ -323,6 +360,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 @@ -371,4 +457,504 @@ public class StringUtils { public var alertErrorTitle: String { return localizedString(for: "Error") } + + /// Authenticating overlay message + /// found in: + /// - AuthPickerView + public var authenticatingMessage: String { + return localizedString(for: "Authenticating...") + } + + /// Two-Factor Authentication + /// found in: + /// - MFAEnrolmentView + /// - MFAManagementView + public var twoFactorAuthenticationLabel: String { + return localizedString(for: "Two-Factor Authentication") + } + + /// Set Up Two-Factor Authentication + /// found in: + /// - MFAEnrolmentView + public var setUpTwoFactorAuthenticationLabel: String { + return localizedString(for: "Set Up Two-Factor Authentication") + } + + /// Manage Two-Factor Authentication + /// found in: + /// - MFAManagementView + public var manageTwoFactorAuthenticationLabel: String { + return localizedString(for: "Manage Two-Factor Authentication") + } + + /// Complete Sign-In + /// found in: + /// - MFAResolutionView + public var completeSignInLabel: String { + return localizedString(for: "Complete Sign-In") + } + + /// Complete Setup + /// found in: + /// - MFAEnrolmentView + public var completeSetupLabel: String { + return localizedString(for: "Complete Setup") + } + + /// Choose Authentication Method + /// found in: + /// - MFAEnrolmentView + /// - MFAResolutionView + public var chooseAuthenticationMethodLabel: String { + return localizedString(for: "Choose Authentication Method") + } + + /// SMS Authentication + /// found in: + /// - MFAEnrolmentView + /// - MFAResolutionView + public var smsAuthenticationLabel: String { + return localizedString(for: "SMS Authentication") + } + + /// Authenticator App + /// found in: + /// - MFAEnrolmentView + /// - MFAResolutionView + public var authenticatorAppLabel: String { + return localizedString(for: "Authenticator App") + } + + /// Enter Your Phone Number + /// found in: + /// - MFAEnrolmentView + /// - EnterPhoneNumberView + public var enterYourPhoneNumberLabel: String { + return localizedString(for: "Enter Your Phone Number") + } + + /// Phone Number + /// found in: + /// - MFAEnrolmentView + /// - EnterPhoneNumberView + public var phoneNumberLabel: String { + return localizedString(for: "Phone Number") + } + + /// Send Code + /// found in: + /// - MFAEnrolmentView + /// - EnterPhoneNumberView + public var sendCodeLabel: String { + return localizedString(for: "Send Code") + } + + /// Enter Verification Code + /// found in: + /// - MFAEnrolmentView + /// - MFAResolutionView + /// - EnterVerificationCodeView + public var enterVerificationCodeLabel: String { + return localizedString(for: "Enter Verification Code") + } + + /// Verification Code + /// found in: + /// - MFAEnrolmentView + /// - MFAResolutionView + /// - EnterVerificationCodeView + public var verificationCodeLabel: String { + return localizedString(for: "Verification Code") + } + + /// Scan QR Code + /// found in: + /// - MFAEnrolmentView + public var scanQRCodeLabel: String { + return localizedString(for: "Scan QR Code") + } + + /// Manual Entry Key: + /// found in: + /// - MFAEnrolmentView + public var manualEntryKeyLabel: String { + return localizedString(for: "Manual Entry Key:") + } + + /// Enter 6-digit code + /// found in: + /// - MFAEnrolmentView + public var enterSixDigitCodeLabel: String { + return localizedString(for: "Enter 6-digit code") + } + + /// Scan with your authenticator app or tap to open directly + /// found in: + /// - MFAEnrolmentView + public var scanWithAuthenticatorAppMessage: String { + return localizedString(for: "Scan with your authenticator app or tap to open directly") + } + + /// Tap to open in authenticator app + /// found in: + /// - MFAEnrolmentView + public var tapToOpenInAuthenticatorAppLabel: String { + return localizedString(for: "Tap to open in authenticator app") + } + + /// Use an authenticator app like Google Authenticator or Authy to generate verification codes. + /// found in: + /// - MFAEnrolmentView + public var authenticatorAppInstructionsMessage: String { + return localizedString( + for: "Use an authenticator app like Google Authenticator or Authy to generate verification codes." + ) + } + + /// Set up two-factor authentication to add an extra layer of security to your account. + /// found in: + /// - MFAEnrolmentView + public var setUpTwoFactorAuthMessage: String { + return localizedString( + for: "Set up two-factor authentication to add an extra layer of security to your account." + ) + } + + /// We'll send a verification code to this number + /// found in: + /// - MFAEnrolmentView + public var sendVerificationCodeToNumberMessage: String { + return localizedString(for: "We'll send a verification code to this number") + } + + /// We'll send a verification code to your phone + /// found in: + /// - MFAEnrolmentView + public var sendVerificationCodeToPhoneMessage: String { + return localizedString(for: "We'll send a verification code to your phone") + } + + /// We'll send a verification code to your phone number each time you sign in. + /// found in: + /// - MFAEnrolmentView + public var sendVerificationCodeEachSignInMessage: String { + return localizedString( + for: "We'll send a verification code to your phone number each time you sign in." + ) + } + + /// Unable to generate QR Code + /// found in: + /// - MFAEnrolmentView + public var unableToGenerateQRCodeMessage: String { + return localizedString(for: "Unable to generate QR Code") + } + + /// Copied to clipboard! + /// found in: + /// - MFAEnrolmentView + public var copiedToClipboardMessage: String { + return localizedString(for: "Copied to clipboard!") + } + + /// Multi-Factor Authentication Disabled + /// found in: + /// - MFAEnrolmentView + /// - MFAResolutionView + public var mfaDisabledLabel: String { + return localizedString(for: "Multi-Factor Authentication Disabled") + } + + /// MFA is not enabled in the current configuration. Please contact your administrator. + /// found in: + /// - MFAEnrolmentView + /// - MFAResolutionView + public var mfaNotEnabledMessage: String { + return localizedString( + for: "MFA is not enabled in the current configuration. Please contact your administrator." + ) + } + + /// No Authentication Methods Available + /// found in: + /// - MFAEnrolmentView + /// - MFAResolutionView + public var noAuthenticationMethodsAvailableLabel: String { + return localizedString(for: "No Authentication Methods Available") + } + + /// No MFA methods are configured as allowed. Please contact your administrator. + /// found in: + /// - MFAEnrolmentView + /// - MFAResolutionView + public var noMFAMethodsConfiguredMessage: String { + return localizedString( + for: "No MFA methods are configured as allowed. Please contact your administrator." + ) + } + + /// Complete sign-in with your second factor + /// found in: + /// - MFAResolutionView + public var completeSignInWithSecondFactorMessage: String { + return localizedString(for: "Complete sign-in with your second factor") + } + + /// Choose verification method: + /// found in: + /// - MFAResolutionView + public var chooseVerificationMethodLabel: String { + return localizedString(for: "Choose verification method:") + } + + /// SMS Verification + /// found in: + /// - MFAResolutionView + public var smsVerificationLabel: String { + return localizedString(for: "SMS Verification") + } + + /// We sent a code to %@ + /// found in: + /// - MFAResolutionView + public var sentCodeToNumberMessage: String { + return localizedString(for: "We sent a code to %@") + } + + /// We'll send a code to ••••••%@ + /// found in: + /// - MFAResolutionView + public var sendCodeToMaskedNumberMessage: String { + return localizedString(for: "We'll send a code to ••••••%@") + } + + /// Enter the 6-digit code from your authenticator app + /// found in: + /// - MFAResolutionView + public var enterCodeFromAuthenticatorAppMessage: String { + return localizedString(for: "Enter the 6-digit code from your authenticator app") + } + + /// Resend Code + /// found in: + /// - MFAResolutionView + /// - EnterVerificationCodeView + public var resendCodeLabel: String { + return localizedString(for: "Resend Code") + } + + /// Change number + /// found in: + /// - EnterVerificationCodeView + public var changeNumberLabel: String { + return localizedString(for: "Change number") + } + + /// Manage your authentication methods + /// found in: + /// - MFAManagementView + public var manageAuthenticationMethodsMessage: String { + return localizedString(for: "Manage your authentication methods") + } + + /// Enrolled Methods + /// found in: + /// - MFAManagementView + public var enrolledMethodsLabel: String { + return localizedString(for: "Enrolled Methods") + } + + /// No Authentication Methods + /// found in: + /// - MFAManagementView + public var noAuthenticationMethodsLabel: String { + return localizedString(for: "No Authentication Methods") + } + + /// Add an extra layer of security to your account + /// found in: + /// - MFAManagementView + public var addExtraSecurityLayerMessage: String { + return localizedString(for: "Add an extra layer of security to your account") + } + + /// Add Another Method + /// found in: + /// - MFAManagementView + public var addAnotherMethodLabel: String { + return localizedString(for: "Add Another Method") + } + + /// Get Started + /// found in: + /// - MFAManagementView + public var getStartedLabel: String { + return localizedString(for: "Get Started") + } + + /// Remove + /// found in: + /// - MFAManagementView + public var removeLabel: String { + return localizedString(for: "Remove") + } + + /// Authentication Method + /// found in: + /// - MFAManagementView + public var authenticationMethodLabel: String { + return localizedString(for: "Authentication Method") + } + + /// Enrolled: %@ + /// found in: + /// - MFAManagementView + public var enrolledDateLabel: String { + return localizedString(for: "Enrolled: %@") + } + + /// SMS: %@ + /// found in: + /// - MFAManagementView + public var smsPhoneLabel: String { + return localizedString(for: "SMS: %@") + } + + /// Delete Account + /// found in: + /// - SignedInView + public var deleteAccountLabel: String { + return localizedString(for: "Delete Account") + } + + /// Delete Account? + /// found in: + /// - SignedInView + public var deleteAccountConfirmationLabel: String { + return localizedString(for: "Delete Account?") + } + + /// This action cannot be undone. All your data will be permanently deleted. You may need to + /// reauthenticate to complete this action. + /// found in: + /// - SignedInView + public var deleteAccountWarningMessage: String { + return localizedString( + for: "This action cannot be undone. All your data will be permanently deleted. You may need to reauthenticate to complete this action." + ) + } + + /// Invalid OAuth Provider error + /// found in: + /// - GenericOAuthButton + public var invalidOAuthProviderError: String { + return localizedString(for: "Invalid OAuth Provider") + } + + // MARK: - Field Labels + + /// Email field label + /// found in: + /// - EmailAuthView + public var emailFieldLabel: String { + return localizedString(for: "Email") + } + + /// Password field label + /// found in: + /// - EmailAuthView + public var passwordFieldLabel: String { + return localizedString(for: "Password") + } + + /// Confirm Password field label + /// found in: + /// - EmailAuthView + public var confirmPasswordFieldLabel: String { + return localizedString(for: "Confirm Password") + } + + /// Phone Number field label + /// found in: + /// - MFAEnrolmentView + public var phoneNumberFieldLabel: String { + return localizedString(for: "Phone Number") + } + + /// Display Name field label + /// found in: + /// - MFAEnrolmentView + public var displayNameFieldLabel: String { + return localizedString(for: "Display Name") + } + + /// Verification Code field label + /// found in: + /// - MFAEnrolmentView + public var verificationCodeFieldLabel: String { + return localizedString(for: "Verification Code") + } + + /// Send a password recovery link to your email field label + /// found in: + /// - PasswordRecoveryView + public var passwordRecoveryEmailFieldLabel: String { + return localizedString(for: "Send a password recovery link to your email") + } + + /// Send a sign-in link to your email field label + /// found in: + /// - EmailLinkView + public var signInLinkEmailFieldLabel: String { + return localizedString(for: "Send a sign-in link to your email") + } + + /// Enter phone number prompt + /// found in: + /// - MFAEnrolmentView + public var enterPhoneNumberPrompt: String { + return localizedString(for: "Enter phone number") + } + + /// Enter display name for this device prompt + /// found in: + /// - MFAEnrolmentView + public var enterDisplayNameForDevicePrompt: String { + return localizedString(for: "Enter display name for this device") + } + + /// Enter display name for this authenticator prompt + /// found in: + /// - MFAEnrolmentView + public var enterDisplayNameForAuthenticatorPrompt: String { + return localizedString(for: "Enter display name for this authenticator") + } + + /// Enter code from app prompt + /// found in: + /// - MFAEnrolmentView + public var enterCodeFromAppPrompt: String { + return localizedString(for: "Enter code from app") + } + + /// Phone field label + /// found in: + /// - EnterPhoneNumberView + public var phoneFieldLabel: String { + return localizedString(for: "Phone") + } + + /// We sent a code to number message + /// found in: + /// - EnterVerificationCodeView + public func sentCodeMessage(phoneNumber: String) -> String { + return String(format: localizedString(for: "We sent a code to %@"), phoneNumber) + } + + /// Change number label + /// found in: + /// - EnterVerificationCodeView + public var changeNumberButtonLabel: String { + return localizedString(for: "Change number") + } } 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..81d197efb7 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -12,116 +12,192 @@ // See the License for the specific language governing permissions and // limitations under the License. +import FirebaseAuth +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor -public struct AuthPickerView { - @Environment(AuthService.self) private var authService - - public init() {} - - private func switchFlow() { - authService.authenticationFlow = authService - .authenticationFlow == .login ? .signUp : .login +public struct AuthPickerView { + public init(@ViewBuilder content: @escaping () -> Content = { EmptyView() }) { + self.content = content } - private var isAuthModalPresented: Binding { - Binding( - get: { authService.isShowingAuthModal }, - set: { authService.isShowingAuthModal = $0 } - ) - } + @Environment(AuthService.self) private var authService + private let content: () -> Content - @ViewBuilder - private var authPickerTitleView: some View { - if authService.authView == .authPicker { - Text(authService.string.authPickerTitle) - .font(.largeTitle) - .fontWeight(.bold) - .padding() - } - } + // View-layer state for handling auto-linking flow + @State private var pendingCredentialForLinking: AuthCredential? } 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() + case AuthView.enterPhoneNumber: + EnterPhoneNumberView() + case let .enterVerificationCode(verificationID, fullPhoneNumber): + EnterVerificationCodeView( + verificationID: verificationID, + fullPhoneNumber: fullPhoneNumber + ) } } - 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) + } + // View-layer logic: Handle account conflicts (auto-handle anonymous upgrade, store others for + // linking) + .onChange(of: authService.currentAccountConflict) { _, conflict in + handleAccountConflict(conflict) + } + // View-layer logic: Auto-link pending credential after successful sign-in + .onChange(of: authService.authenticationState) { _, newState in + if newState == .authenticated { + attemptAutoLinkPendingCredential() + } + } + } - Divider() + /// View-layer logic: Handle account conflicts with type-specific behavior + private func handleAccountConflict(_ conflict: AccountConflictContext?) { + guard let conflict = conflict else { return } + + // Only auto-handle anonymous upgrade conflicts + if conflict.conflictType == .anonymousUpgradeConflict { + Task { + do { + // Sign out the anonymous user + try await authService.signOut() + + // Sign in with the new credential + _ = try await authService.signIn(credentials: conflict.credential) + + // Successfully handled - conflict and error are cleared automatically by reset() + } catch { + // Error will be shown via normal error handling + // Credential is still stored if they want to retry + } + } + } else { + // Other conflicts: store credential for potential linking after sign-in + pendingCredentialForLinking = conflict.credential + // Error modal will show for user to see and handle + } + } - if let view = authService.viewForCurrentModal() { - view - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() + /// View-layer logic: Attempt to link pending credential after successful sign-in + private func attemptAutoLinkPendingCredential() { + guard let credential = pendingCredentialForLinking else { return } + + Task { + do { + try await authService.linkAccounts(credentials: credential) + // Successfully linked, clear the pending credential + pendingCredentialForLinking = nil + } catch { + // Silently swallow linking errors - user is already signed in + // Consumer's custom views can observe authService.currentError if they want to handle this + pendingCredentialForLinking = nil + } + } + } + + @ToolbarContentBuilder + var toolbar: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + if !authService.configuration.shouldHideCancelButton { + Button { + authService.isPresented = false + } label: { + Image(systemName: "xmark") + .foregroundStyle(Color(UIColor.label)) + } + } + } + } + + @ViewBuilder + var authPickerViewInternal: some View { + @Bindable var authService = authService + VStack { + if authService.authenticationState == .authenticated { + SignedInView() + } else { + authMethodPicker + .safeAreaPadding() + } + } + .overlay { + if authService.authenticationState == .authenticating { + VStack(spacing: 24) { + ProgressView() + .scaleEffect(1.25) + .tint(.white) + Text("Authenticating...") + .foregroundStyle(.white) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.black.opacity(0.7)) + } + } + .errorAlert( + error: authService.currentError, + okButtonLabel: authService.string.okButtonLabel + ) + } + + @ViewBuilder + var authMethodPicker: some View { + GeometryReader { proxy in + ScrollView { + VStack(spacing: 24) { + Image(authService.configuration.logo ?? Assets.firebaseAuthLogo) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 100, height: 100) + if authService.emailSignInEnabled { + EmailAuthView() } + Divider() + otherSignInOptions(proxy) + PrivacyTOCsView(displayMode: .full) } } } } + + @ViewBuilder + func otherSignInOptions(_ proxy: GeometryProxy) -> some View { + VStack { + authService.renderButtons() + } + .padding(.horizontal, proxy.size.width * 0.18) + } } #Preview { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index 739bbebe11..52fc68bcf5 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 @@ -41,7 +42,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 +50,94 @@ public struct EmailAuthView { } private func signInWithEmailPassword() async { - do { - try await authService.signIn(withEmail: email, password: password) - } catch {} + try? await authService.signIn(email: email, password: password) } private func createUserWithEmailPassword() async { - do { - try await authService.createUser(withEmail: email, password: password) - } catch {} + 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, + label: authService.string.emailFieldLabel, + 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, + label: authService.string.passwordFieldLabel, + 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, + label: authService.string.confirmPasswordFieldLabel, + 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,16 +146,36 @@ extension EmailAuthView: View { } } .disabled(!isValid) - .padding([.top, .bottom, .horizontal], 8) + .padding([.top, .bottom], 8) .frame(maxWidth: .infinity) .buttonStyle(.borderedProminent) .accessibilityIdentifier("sign-in-button") - Button(action: { - authService.authView = .emailLink - }) { - Text(authService.string.signUpWithEmailLinkButtonLabel) - }.accessibilityIdentifier("sign-in-with-email-link-button") } + Button(action: { + withAnimation { + authService.authenticationFlow = + authService + .authenticationFlow == .signIn ? .signUp : .signIn + } + }) { + HStack(spacing: 4) { + Text( + authService + .authenticationFlow == .signIn + ? authService.string.dontHaveAnAccountYetLabel + : authService.string.alreadyHaveAnAccountLabel + ) + .foregroundStyle(Color(.label)) + 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..14690cda82 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, + label: authService.string.signInLinkEmailFieldLabel, + 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/EnterPhoneNumberView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift new file mode 100644 index 0000000000..deaee4706d --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift @@ -0,0 +1,90 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +import FirebaseAuthUIComponents +import FirebaseCore +import SwiftUI + +struct EnterPhoneNumberView: View { + @Environment(AuthService.self) private var authService + @State private var phoneNumber: String = "" + @State private var selectedCountry: CountryData = .default + + var body: some View { + VStack(spacing: 16) { + Text(authService.string.enterPhoneNumberPlaceholder) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top) + + AuthTextField( + text: $phoneNumber, + label: authService.string.phoneFieldLabel, + prompt: authService.string.enterPhoneNumberPlaceholder, + keyboardType: .phonePad, + contentType: .telephoneNumber, + onChange: { _ in } + ) { + CountrySelector( + selectedCountry: $selectedCountry, + enabled: !(authService.authenticationState == .authenticating) + ) + } + + Button(action: { + Task { + do { + guard let provider = authService.currentPhoneProvider else { + fatalError("No phone provider found") + } + let fullPhoneNumber = selectedCountry.dialCode + phoneNumber + let id = try await provider.verifyPhoneNumber(phoneNumber: fullPhoneNumber) + authService.navigator.push(.enterVerificationCode( + verificationID: id, + fullPhoneNumber: fullPhoneNumber + )) + } catch {} + } + }) { + if authService.authenticationState == .authenticating { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.sendCodeButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(authService.authenticationState == .authenticating || phoneNumber.isEmpty) + .padding(.top, 8) + + Spacer() + } + .navigationTitle(authService.string.phoneSignInTitle) + .padding(.horizontal) + .errorAlert(error: authService.currentError, okButtonLabel: authService.string.okButtonLabel) + } +} + +#Preview { + FirebaseOptions.dummyConfigurationForPreview() + + return EnterPhoneNumberView() + .environment(AuthService()) +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift new file mode 100644 index 0000000000..b8400056e5 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift @@ -0,0 +1,101 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +import FirebaseAuthUIComponents +import FirebaseCore +import SwiftUI + +@MainActor +struct EnterVerificationCodeView: View { + @Environment(AuthService.self) private var authService + @State private var verificationCode: String = "" + + let verificationID: String + let fullPhoneNumber: String + + var body: some View { + @Bindable var authService = authService + VStack(spacing: 32) { + VStack(spacing: 16) { + VStack(spacing: 8) { + Text(authService.string.sentCodeMessage(phoneNumber: fullPhoneNumber)) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + authService.navigator.pop() + } label: { + Text(authService.string.changeNumberButtonLabel) + .font(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.bottom) + .frame(maxWidth: .infinity, alignment: .leading) + + VerificationCodeInputField(code: $verificationCode) + + Button(action: { + Task { + do { + guard let provider = authService.currentPhoneProvider else { + fatalError("No phone provider found") + } + let credential = try await provider.createAuthCredential( + verificationId: verificationID, + verificationCode: verificationCode + ) + + _ = try await authService.signIn(credentials: credential) + authService.navigator.clear() + } catch {} + } + }) { + if authService.authenticationState == .authenticating { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.verifyAndSignInButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(authService.authenticationState == .authenticating || verificationCode.count != 6) + } + + Spacer() + } + .navigationTitle(authService.string.enterVerificationCodeTitle) + .navigationBarTitleDisplayMode(.large) + .padding(.horizontal) + .errorAlert(error: authService.currentError, okButtonLabel: authService.string.okButtonLabel) + } +} + +#Preview { + FirebaseOptions.dummyConfigurationForPreview() + + return NavigationStack { + EnterVerificationCodeView( + verificationID: "mock-id", + fullPhoneNumber: "+1 5551234567", + ) + .environment(AuthService()) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift new file mode 100644 index 0000000000..f465cb0b22 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ErrorAlertView.swift @@ -0,0 +1,85 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +import SwiftUI + +/// A reusable view modifier that displays error messages in an alert modal +struct ErrorAlertModifier: ViewModifier { + @Binding var error: AlertError? + let okButtonLabel: String + + private func shouldShowAlert(for error: AlertError?) -> Bool { + // View layer decides which errors should show an alert + guard let error = error else { return false } + + // Don't show alert for CancellationError + if error.underlyingError is CancellationError { + return false + } + + // Don't show alert for anonymous upgrade conflicts (they're auto-handled) + if let authError = error.underlyingError as? AuthServiceError, + case let .accountConflict(context) = authError, + context.conflictType == .anonymousUpgradeConflict { + return false + } + + return true + } + + func body(content: Content) -> some View { + let shouldShow = shouldShowAlert(for: error) + + return content + .alert(isPresented: Binding( + get: { shouldShow }, + 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, Equatable { + 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 + } + + public static func == (lhs: AlertError, rhs: AlertError) -> Bool { + // Compare by id since each AlertError instance is unique + lhs.id == rhs.id + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift new file mode 100644 index 0000000000..8f0185b99f --- /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, + label: authService.string.phoneNumberFieldLabel, + prompt: authService.string.enterPhoneNumberPrompt, + keyboardType: .phonePad, + contentType: .telephoneNumber, + onChange: { _ in } + ) { + CountrySelector( + selectedCountry: $selectedCountry, + enabled: !isLoading + ) + } + .focused($focus, equals: .phoneNumber) + .accessibilityIdentifier("phone-number-field") + + AuthTextField( + text: $displayName, + label: authService.string.displayNameFieldLabel, + prompt: authService.string.enterDisplayNameForDevicePrompt, + 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, + label: authService.string.verificationCodeFieldLabel, + 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, + label: authService.string.displayNameFieldLabel, + prompt: authService.string.enterDisplayNameForAuthenticatorPrompt, + leading: { + Image(systemName: "person") + } + ) + .accessibilityIdentifier("display-name-field") + + AuthTextField( + text: $totpCode, + label: authService.string.verificationCodeFieldLabel, + prompt: authService.string.enterCodeFromAppPrompt, + keyboardType: .numberPad, + contentType: .oneTimeCode, + leading: { + Image(systemName: "number") + } + ) + .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..f14346d4d2 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, + label: authService.string.passwordRecoveryEmailFieldLabel, + 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 60% rename from FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePassword.swift rename to FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/UpdatePasswordView.swift index c1a43d422e..1e54cfe4d6 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, + label: "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, + label: "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..628dd15462 --- /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 label: 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, + label: 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.label = label + 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(LocalizedStringResource(stringLiteral: label)) + HStack(spacing: 8) { + leading() + Group { + if isSecureTextField { + ZStack(alignment: .trailing) { + SecureField(label, text: $text, prompt: Text(prompt)) + .opacity(obscured ? 1 : 0) + .focused($isFocused) + .frame(height: 24) + TextField(label, 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( + label, + 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..a20738bf4f 100644 --- a/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift +++ b/FirebaseSwiftUI/FirebaseFacebookSwiftUI/Sources/Views/SignInWithFacebookButton.swift @@ -12,113 +12,32 @@ // 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 + 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) - } - } - } - }) { - 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)) - ) + try? await authService.signIn(facebookProvider) } } } @@ -126,6 +45,7 @@ extension SignInWithFacebookButton: View { #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..e967466813 100644 --- a/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift +++ b/FirebaseSwiftUI/FirebaseGoogleSwiftUI/Sources/Views/SignInWithGoogleButton.swift @@ -19,26 +19,29 @@ // 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 + 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: authService.string.googleLoginButtonLabel, + style: .google, + accessibilityId: "sign-in-with-google-button" + ) { Task { - try await authService.signInWithGoogle() + try? await authService.signIn(googleProvider) } } } @@ -46,6 +49,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..2ad48792b9 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseOAuthSwiftUI/Sources/Views/GenericOAuthButton.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 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 + 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(authService.string.invalidOAuthProviderError) + .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 { + 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..4d0c5743bf 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift @@ -18,12 +18,8 @@ import SwiftUI public typealias VerificationID = String -public class PhoneAuthProviderAuthUI: @preconcurrency PhoneAuthProviderAuthUIProtocol { - public let id: String = "phone" - - @MainActor public func authButton() -> AnyView { - AnyView(PhoneAuthButtonView()) - } +public class PhoneProviderSwift: PhoneAuthProviderSwift { + public init() {} @MainActor public func verifyPhoneNumber(phoneNumber: String) async throws -> VerificationID { return try await withCheckedThrowingContinuation { continuation in @@ -37,4 +33,28 @@ public class PhoneAuthProviderAuthUI: @preconcurrency PhoneAuthProviderAuthUIPro } } } + + @MainActor public func createAuthCredential() async throws -> AuthCredential { + fatalError("Not implemented") + } + + @MainActor public func createAuthCredential(verificationId: String, + verificationCode: String) async throws + -> AuthCredential { + return PhoneAuthProvider.provider() + .credential(withVerificationID: verificationId, verificationCode: verificationCode) + } +} + +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..5f24d7cf35 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Views/PhoneAuthButtonView.swift @@ -13,36 +13,35 @@ // limitations under the License. import FirebaseAuthSwiftUI +import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor public struct PhoneAuthButtonView { @Environment(AuthService.self) private var authService + 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)) - } - 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) + AuthProviderButton( + label: authService.string.phoneLoginButtonLabel, + style: .phone, + accessibilityId: "sign-in-with-phone-button" + ) { + authService.navigator.push(.enterPhoneNumber) } } } #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..28d67e9c60 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseTwitterSwiftUI/Sources/Views/SignInWithTwitterButton.swift @@ -0,0 +1,41 @@ +// 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 + let provider: AuthProviderSwift + public init(provider: AuthProviderSwift) { + self.provider = provider + } +} + +extension SignInWithTwitterButton: View { + public var body: some View { + AuthProviderButton( + label: authService.string.twitterLoginButtonLabel, + style: .twitter, + accessibilityId: "sign-in-with-twitter-button" + ) { + Task { + 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..abec964899 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 */; @@ -472,7 +481,7 @@ PRODUCT_BUNDLE_IDENTIFIER = io.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; @@ -505,7 +514,7 @@ PRODUCT_BUNDLE_IDENTIFIER = io.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/App/ContentView.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/ContentView.swift new file mode 100644 index 0000000000..add1a85d3b --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/ContentView.swift @@ -0,0 +1,119 @@ +// 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. + +// +// ContentView.swift +// FirebaseSwiftUIExample +// +// Created by Russell Wheatley on 23/04/2025. +// + +import AppTrackingTransparency +import FirebaseAppleSwiftUI +import FirebaseAuth +import FirebaseAuthSwiftUI +import FirebaseFacebookSwiftUI +import FirebaseGoogleSwiftUI +import FirebaseOAuthSwiftUI +import FirebasePhoneAuthSwiftUI +import FirebaseTwitterSwiftUI +import SwiftUI + +struct ContentView: View { + init() { + Auth.auth().signInAnonymously() + let actionCodeSettings = ActionCodeSettings() + actionCodeSettings.handleCodeInApp = true + actionCodeSettings.url = URL(string: "https://flutterfire-e2e-tests.firebaseapp.com") + actionCodeSettings.setIOSBundleID(Bundle.main.bundleIdentifier!) + actionCodeSettings.linkDomain = "flutterfire-e2e-tests.firebaseapp.com" + let configuration = AuthConfiguration( + shouldAutoUpgradeAnonymousUsers: true, + tosUrl: URL(string: "https://example.com/tos"), + privacyPolicyUrl: URL(string: "https://example.com/privacy"), + emailLinkSignInActionCodeSettings: actionCodeSettings, + mfaEnabled: true + ) + + authService = AuthService( + configuration: configuration + ) + .withAppleSignIn() + .withPhoneSignIn() + .withGoogleSignIn() + .withFacebookSignIn(FacebookProviderSwift()) + .withTwitterSignIn() + .withOAuthSignIn(OAuthProviderSwift.github()) + .withOAuthSignIn(OAuthProviderSwift.microsoft()) + .withOAuthSignIn(OAuthProviderSwift.yahoo()) + .withOAuthSignIn( + OAuthProviderSwift( + providerId: "oidc.line", + displayName: "Sign in with LINE", + buttonIcon: Image(.icLineLogo), + buttonBackgroundColor: .lineButton, + buttonForegroundColor: .white + ) + ) + .withEmailSignIn() + } + + let authService: AuthService + + var body: some View { + AuthPickerView { + usersApp + } + .environment(authService) + } + + var usersApp: some View { + NavigationStack { + VStack { + if authService.authenticationState == .unauthenticated { + Text("Not Authenticated") + Button { + authService.isPresented = true + } label: { + Text("Authenticate") + } + .buttonStyle(.bordered) + } else { + Text("Authenticated - \(authService.currentUser?.email ?? "")") + Button { + authService.isPresented = true // Reopen the sheet + } label: { + Text("Manage Account") + } + .buttonStyle(.bordered) + Button { + Task { + try? await authService.signOut() + } + } label: { + Text("Sign Out") + } + .buttonStyle(.borderedProminent) + } + } + .navigationTitle("Firebase UI Demo") + } + .onChange(of: authService.authenticationState) { _, newValue in + debugPrint("authService.authenticationState - \(newValue)") + if newValue != .authenticating { + authService.isPresented = newValue == .unauthenticated + } + } + } +} diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExampleApp.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/FirebaseSwiftUIExampleApp.swift similarity index 93% rename from samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExampleApp.swift rename to samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/FirebaseSwiftUIExampleApp.swift index 630fdb6603..735bbbb0f5 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExampleApp.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/App/FirebaseSwiftUIExampleApp.swift @@ -30,9 +30,6 @@ class AppDelegate: NSObject, UIApplicationDelegate { UIApplication.LaunchOptionsKey: Any ]?) -> 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..58e1cda1f4 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExample.entitlements +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/FirebaseSwiftUIExample.entitlements @@ -4,9 +4,14 @@ aps-environment development + com.apple.developer.applesignin + + Default + com.apple.developer.associated-domains applinks:flutterfire-e2e-tests.firebaseapp.com + applinks:flutterfiretests.page.link diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Info.plist b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Info.plist index 968fe3cff8..972c78a5b8 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Info.plist +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/Info.plist @@ -2,6 +2,13 @@ + CFBundleLocalizations + + en + es + de + fr + CFBundleURLTypes @@ -29,6 +36,8 @@ 16dbbdf0cfb309034a6ad98ac2a21688 FacebookDisplayName Firebase Swift UI App + FirebaseAppDelegateProxyEnabled + LSApplicationQueriesSchemes fbapi @@ -39,7 +48,5 @@ fetch remote-notification - FirebaseAppDelegateProxyEnabled - diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/TestView.swift similarity index 65% rename from samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift rename to samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/TestView.swift index e51ef876cd..6a125f75fe 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/ContentView.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/TestView.swift @@ -19,18 +19,31 @@ // 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() + } + if anonymousSignInEnabled { + Auth.auth().signInAnonymously() + } + + let isMfaEnabled = ProcessInfo.processInfo.arguments.contains("--mfa-enabled") let actionCodeSettings = ActionCodeSettings() actionCodeSettings.handleCodeInApp = true @@ -39,10 +52,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 +63,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..938d207dc4 100644 --- a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/UITestUtils.swift +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExample/UITestUtils.swift @@ -8,20 +8,10 @@ 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] -} +public let anonymousSignInEnabled = CommandLine.arguments.contains("--anonymous-sign-in-enabled") -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..9a8c177ee0 --- /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"]) + } +} diff --git a/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/UpgradeAccountUITests.swift b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/UpgradeAccountUITests.swift new file mode 100644 index 0000000000..5ed9b27e0b --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests/UpgradeAccountUITests.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. + +// +// UpgradeAccountUITests.swift +// UpgradeAccountUITests +// +// Created by Russell Wheatley on 05/11/2025. +// + +import XCTest + +final class UpgradeAccountUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws {} + + @MainActor + func testUpgradeAnonymousAccountWithEmailPassword() async throws { + // Create a test user first + let email = createEmail() + let password = "123456" + try await createTestUser(email: email, password: password) + + // Launch app with anonymous sign-in enabled + let app = createTestApp() + app.launchArguments.append("--anonymous-sign-in-enabled") + app.launch() + + // Wait for sign-in screen to appear + let emailField = app.textFields["email-field"] + XCTAssertTrue(emailField.waitForExistence(timeout: 6), "Email field should exist") + emailField.tap() + emailField.typeText(email) + + let passwordField = app.secureTextFields["password-field"] + XCTAssertTrue(passwordField.exists, "Password field should exist") + passwordField.tap() + passwordField.typeText(password) + + let signInButton = app.buttons["sign-in-button"] + XCTAssertTrue(signInButton.exists, "Sign-In button should exist") + signInButton.tap() + + let signedInText = app.staticTexts["signed-in-text"] + + // Wait for authentication to complete and signed-in view to appear + XCTAssertTrue( + signedInText.waitForExistence(timeout: 30), + "SignedInView should be visible after signing in with email/password" + ) + } +}