diff --git a/.gitignore b/.gitignore index 112d69df5e..750ac09ac2 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ DerivedData .build/ # Third Party /sdk +vendor/ # Generated /target @@ -29,4 +30,4 @@ Pods/ Podfile.lock *.xcworkspace/ samples/**/GoogleService-Info.plist -e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests.xcresult/ \ No newline at end of file +e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleUITests.xcresult/ diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift index 7865931f7c..6b19ad993d 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift @@ -17,6 +17,12 @@ import Foundation import SwiftUI public struct AuthConfiguration { + public enum DeleteAccountButtonAction { + case showConfirmationSheet + case hidden + case custom(@MainActor () -> Void) + } + public let logo: ImageResource? public let languageCode: String? public let shouldHideCancelButton: Bool @@ -27,6 +33,7 @@ public struct AuthConfiguration { public let privacyPolicyUrl: URL? public let emailLinkSignInActionCodeSettings: ActionCodeSettings? public let verifyEmailActionCodeSettings: ActionCodeSettings? + public let deleteAccountButtonAction: DeleteAccountButtonAction // MARK: - MFA Configuration @@ -44,6 +51,7 @@ public struct AuthConfiguration { privacyPolicyUrl: URL? = nil, emailLinkSignInActionCodeSettings: ActionCodeSettings? = nil, verifyEmailActionCodeSettings: ActionCodeSettings? = nil, + deleteAccountButtonAction: DeleteAccountButtonAction = .showConfirmationSheet, mfaEnabled: Bool = false, allowedSecondFactors: Set = [.sms, .totp], mfaIssuer: String = "Firebase Auth") { @@ -57,6 +65,7 @@ public struct AuthConfiguration { self.privacyPolicyUrl = privacyPolicyUrl self.emailLinkSignInActionCodeSettings = emailLinkSignInActionCodeSettings self.verifyEmailActionCodeSettings = verifyEmailActionCodeSettings + self.deleteAccountButtonAction = deleteAccountButtonAction self.mfaEnabled = mfaEnabled self.allowedSecondFactors = allowedSecondFactors self.mfaIssuer = mfaIssuer diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift index cb886ddd0f..17de2b48a1 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift @@ -23,6 +23,38 @@ public struct SignedInView { @State private var showEmailVerificationSent = false @State private var reauthCoordinator = ReauthenticationCoordinator() + private var showsMfaManagementButton: Bool { + Self.showsMfaManagementButton(configuration: authService.configuration) + } + + private var showsDeleteAccountButton: Bool { + Self.showsDeleteAccountButton(configuration: authService.configuration) + } + + static func showsMfaManagementButton(configuration: AuthConfiguration) -> Bool { + configuration.mfaEnabled + } + + static func showsDeleteAccountButton(configuration: AuthConfiguration) -> Bool { + switch configuration.deleteAccountButtonAction { + case .hidden: + return false + case .showConfirmationSheet, .custom: + return true + } + } + + private func performDeleteAccountButtonAction() { + switch authService.configuration.deleteAccountButtonAction { + case .showConfirmationSheet: + showDeleteConfirmation = true + case .hidden: + break + case let .custom(action): + action() + } + } + private func sendEmailVerification() async throws { do { try await authService.sendEmailVerification() @@ -75,29 +107,33 @@ extension SignedInView: View { .frame(maxWidth: .infinity) .accessibilityIdentifier("update-password-button") - Button { - authService.navigator.push(.mfaManagement) - } label: { - Text(authService.string.manageTwoFactorAuthenticationLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) + if showsMfaManagementButton { + Button { + authService.navigator.push(.mfaManagement) + } label: { + Text(authService.string.manageTwoFactorAuthenticationLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("mfa-management-button") } - .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) + if showsDeleteAccountButton { + Button { + performDeleteAccountButtonAction() + } label: { + Text(authService.string.deleteAccountButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("delete-account-button") } - .buttonStyle(.borderedProminent) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("delete-account-button") Button { Task { @@ -175,24 +211,22 @@ private struct DeleteAccountConfirmationSheet: View { .font(.system(size: 60)) .foregroundColor(.red) - Text("Delete Account?") + Text(authService.string.deleteAccountConfirmationLabel) .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) + Text(authService.string.deleteAccountWarningMessage) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) } VStack(spacing: 12) { Button { onConfirm() } label: { - Text("Delete Account") + Text(authService.string.deleteAccountLabel) .padding(.vertical, 8) .frame(maxWidth: .infinity) } @@ -205,7 +239,7 @@ private struct DeleteAccountConfirmationSheet: View { Button { onCancel() } label: { - Text("Cancel") + Text(authService.string.cancelButtonLabel) .padding(.vertical, 8) .frame(maxWidth: .infinity) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/SignedInViewTests.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/SignedInViewTests.swift new file mode 100644 index 0000000000..19c97a9c72 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/SignedInViewTests.swift @@ -0,0 +1,54 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@testable import FirebaseAuthSwiftUI +import Testing + +@Suite("SignedInView Tests") +struct SignedInViewTests { + @Test("MFA management button is hidden when MFA is disabled") + func mfaManagementButtonHiddenWhenMfaDisabled() { + let configuration = AuthConfiguration(mfaEnabled: false) + + #expect(SignedInView.showsMfaManagementButton(configuration: configuration) == false) + } + + @Test("MFA management button is shown when MFA is enabled") + func mfaManagementButtonShownWhenMfaEnabled() { + let configuration = AuthConfiguration(mfaEnabled: true) + + #expect(SignedInView.showsMfaManagementButton(configuration: configuration) == true) + } + + @Test("Delete account button is shown by default") + func deleteAccountButtonShownByDefault() { + let configuration = AuthConfiguration() + + #expect(SignedInView.showsDeleteAccountButton(configuration: configuration) == true) + } + + @Test("Delete account button is hidden when configured") + func deleteAccountButtonHiddenWhenConfigured() { + let configuration = AuthConfiguration(deleteAccountButtonAction: .hidden) + + #expect(SignedInView.showsDeleteAccountButton(configuration: configuration) == false) + } + + @Test("Delete account button is shown for custom actions") + func deleteAccountButtonShownForCustomAction() { + let configuration = AuthConfiguration(deleteAccountButtonAction: .custom {}) + + #expect(SignedInView.showsDeleteAccountButton(configuration: configuration) == true) + } +} diff --git a/FirebaseSwiftUI/README.md b/FirebaseSwiftUI/README.md index 3ca110b1db..41cfc3c500 100644 --- a/FirebaseSwiftUI/README.md +++ b/FirebaseSwiftUI/README.md @@ -812,6 +812,28 @@ let configuration = AuthConfiguration(customStringsBundle: .main) See [example implementation](../../samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample) for a working demo. +### Customizing Account Deletion + +By default, `SignedInView` shows a **Delete Account** button that opens the built-in confirmation sheet and deletes the current Firebase Auth user after reauthentication when required. + +Hide the button if account deletion is handled elsewhere in your app: + +```swift +let configuration = AuthConfiguration( + deleteAccountButtonAction: .hidden +) +``` + +Or provide a custom tap action to show your own confirmation UI, route to a support flow, or run app-specific cleanup before deleting the account: + +```swift +let configuration = AuthConfiguration( + deleteAccountButtonAction: .custom { + // Present your own delete-account flow. + } +) +``` + --- @@ -834,6 +856,7 @@ public struct AuthConfiguration { privacyPolicyUrl: URL? = nil, emailLinkSignInActionCodeSettings: ActionCodeSettings? = nil, verifyEmailActionCodeSettings: ActionCodeSettings? = nil, + deleteAccountButtonAction: DeleteAccountButtonAction = .showConfirmationSheet, mfaEnabled: Bool = false, allowedSecondFactors: Set = [.sms, .totp], mfaIssuer: String = "Firebase Auth" @@ -855,6 +878,7 @@ public struct AuthConfiguration { | `privacyPolicyUrl` | `URL?` | `nil` | URL to your Privacy Policy. When both `tosUrl` and `privacyPolicyUrl` are set, links are displayed in the auth UI. | | `emailLinkSignInActionCodeSettings` | `ActionCodeSettings?` | `nil` | Configuration for email link (passwordless) sign-in. Must be set to use email link authentication. | | `verifyEmailActionCodeSettings` | `ActionCodeSettings?` | `nil` | Configuration for email verification. Used when sending verification emails to users. | +| `deleteAccountButtonAction` | `DeleteAccountButtonAction` | `.showConfirmationSheet` | Controls the Delete Account button in `SignedInView`. Use `.showConfirmationSheet` for the built-in deletion flow, `.hidden` to remove the button, or `.custom { ... }` to run your own action when tapped. | | `mfaEnabled` | `Bool` | `false` | Enables Multi-Factor Authentication support. When enabled, users can enroll in and use MFA. | | `allowedSecondFactors` | `Set` | `[.sms, .totp]` | Set of allowed MFA factor types. Options are `.sms` (phone-based) and `.totp` (authenticator app). | | `mfaIssuer` | `String` | `"Firebase Auth"` | The issuer name displayed in TOTP authenticator apps when users enroll. | @@ -1737,4 +1761,3 @@ Thrown by sensitive operations when Firebase requires recent authentication. Eac ## Feedback Please file feedback and issues in the [repository's issue tracker](https://github.com/firebase/FirebaseUI-iOS/issues). -